新增个人访问令牌、使用统计与配置校验功能

This commit is contained in:
2026-01-31 17:14:58 +08:00
parent 1cbb01ccc4
commit 0e02b14582
10 changed files with 980 additions and 149 deletions

View File

@@ -19,6 +19,8 @@
## 安装 ## 安装
目前,整体工具还在开发,并不保证各项功能准确达到既定目标。但依然十分欢迎参与贡献、反馈问题和建议。
```bash ```bash
git clone https://github.com/yourusername/quicommit.git git clone https://github.com/yourusername/quicommit.git
cd quicommit cd quicommit

View File

@@ -7,7 +7,7 @@ use crate::config::manager::ConfigManager;
use crate::config::CommitFormat; use crate::config::CommitFormat;
use crate::generator::ContentGenerator; use crate::generator::ContentGenerator;
use crate::git::{find_repo, GitRepo}; 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; use crate::utils::validators::get_commit_types;
/// Generate and execute conventional commits /// Generate and execute conventional commits

View File

@@ -1,4 +1,4 @@
use anyhow::{Context, Result}; 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};

View File

@@ -4,9 +4,9 @@ use colored::Colorize;
use dialoguer::{Confirm, Input, Select}; use dialoguer::{Confirm, Input, Select};
use crate::config::manager::ConfigManager; use crate::config::manager::ConfigManager;
use crate::config::{GitProfile}; use crate::config::{GitProfile, TokenConfig, TokenType, ProfileComparison};
use crate::config::profile::{GpgConfig, SshConfig}; 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; use crate::utils::validators::validate_profile_name;
/// Manage Git profiles /// Manage Git profiles
@@ -75,6 +75,51 @@ enum ProfileSubcommand {
/// New profile name /// New profile name
to: String, to: String,
}, },
/// Manage tokens for a profile
Token {
#[command(subcommand)]
token_command: TokenSubcommand,
},
/// Check profile configuration against git
Check {
/// Profile name
name: Option<String>,
},
/// Show usage statistics
Stats {
/// Profile name
name: Option<String>,
},
}
#[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 { impl ProfileCommand {
@@ -90,6 +135,9 @@ impl ProfileCommand {
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).await,
Some(ProfileSubcommand::Switch) => self.switch_profile().await, Some(ProfileSubcommand::Switch) => self.switch_profile().await,
Some(ProfileSubcommand::Copy { from, to }) => self.copy_profile(from, to).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, None => self.list_profiles().await,
} }
} }
@@ -148,7 +196,6 @@ impl ProfileCommand {
profile.is_work = is_work; profile.is_work = is_work;
profile.organization = organization; profile.organization = organization;
// SSH configuration
let setup_ssh = Confirm::new() let setup_ssh = Confirm::new()
.with_prompt("Configure SSH key?") .with_prompt("Configure SSH key?")
.default(false) .default(false)
@@ -158,7 +205,6 @@ impl ProfileCommand {
profile.ssh = Some(self.setup_ssh_interactive().await?); profile.ssh = Some(self.setup_ssh_interactive().await?);
} }
// GPG configuration
let setup_gpg = Confirm::new() let setup_gpg = Confirm::new()
.with_prompt("Configure GPG signing?") .with_prompt("Configure GPG signing?")
.default(false) .default(false)
@@ -168,12 +214,20 @@ impl ProfileCommand {
profile.gpg = Some(self.setup_gpg_interactive().await?); 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.add_profile(name.clone(), profile)?;
manager.save()?; manager.save()?;
println!("{} Profile '{}' added successfully", "".green(), name.cyan()); println!("{} Profile '{}' added successfully", "".green(), name.cyan());
// Offer to set as default
if manager.default_profile().is_none() { if manager.default_profile().is_none() {
let set_default = Confirm::new() let set_default = Confirm::new()
.with_prompt("Set as default profile?") .with_prompt("Set as default profile?")
@@ -251,6 +305,13 @@ impl ProfileCommand {
if profile.has_gpg() { if profile.has_gpg() {
println!(" {} GPG configured", "🔒".to_string().dimmed()); 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!(); println!();
} }
@@ -298,6 +359,24 @@ impl ProfileCommand {
println!(" Use agent: {}", if gpg.use_agent { "yes" } else { "no" }); 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(()) Ok(())
} }
@@ -330,6 +409,8 @@ impl ProfileCommand {
new_profile.organization = profile.organization; new_profile.organization = profile.organization;
new_profile.ssh = profile.ssh; new_profile.ssh = profile.ssh;
new_profile.gpg = profile.gpg; new_profile.gpg = profile.gpg;
new_profile.tokens = profile.tokens;
new_profile.usage = profile.usage;
manager.update_profile(name, new_profile)?; manager.update_profile(name, new_profile)?;
manager.save()?; manager.save()?;
@@ -365,18 +446,27 @@ impl ProfileCommand {
} }
async fn apply_profile(&self, name: Option<&str>, global: bool) -> Result<()> { 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 { let profile_name = if let Some(n) = name {
manager.get_profile(n) n.to_string()
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", n))?
.clone()
} else { } else {
manager.default_profile() manager.default_profile_name()
.ok_or_else(|| anyhow::anyhow!("No default profile set"))? .ok_or_else(|| anyhow::anyhow!("No default profile set"))?
.clone() .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 { if global {
profile.apply_global()?; profile.apply_global()?;
println!("{} Applied profile '{}' globally", "".green(), profile.name.cyan()); println!("{} Applied profile '{}' globally", "".green(), profile.name.cyan());
@@ -386,6 +476,9 @@ impl ProfileCommand {
println!("{} Applied profile '{}' to current repository", "".green(), profile.name.cyan()); println!("{} Applied profile '{}' to current repository", "".green(), profile.name.cyan());
} }
manager.record_profile_usage(&profile_name, repo_path)?;
manager.save()?;
Ok(()) Ok(())
} }
@@ -419,7 +512,6 @@ impl ProfileCommand {
println!("{} Switched to profile '{}'", "".green(), selected.cyan()); println!("{} Switched to profile '{}'", "".green(), selected.cyan());
// Offer to apply to current repo
if find_repo(".").is_ok() { if find_repo(".").is_ok() {
let apply = Confirm::new() let apply = Confirm::new()
.with_prompt("Apply to current repository?") .with_prompt("Apply to current repository?")
@@ -445,6 +537,7 @@ impl ProfileCommand {
let mut new_profile = source.clone(); let mut new_profile = source.clone();
new_profile.name = to.to_string(); new_profile.name = to.to_string();
new_profile.usage = Default::default();
manager.add_profile(to.to_string(), new_profile)?; manager.add_profile(to.to_string(), new_profile)?;
manager.save()?; manager.save()?;
@@ -454,6 +547,192 @@ impl ProfileCommand {
Ok(()) 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<SshConfig> { async fn setup_ssh_interactive(&self) -> Result<SshConfig> {
use std::path::PathBuf; use std::path::PathBuf;
@@ -489,4 +768,19 @@ impl ProfileCommand {
use_agent: true, 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(())
}
} }

View File

@@ -1,4 +1,4 @@
use anyhow::{bail, Context, Result}; use anyhow::{bail, Result};
use clap::Parser; use clap::Parser;
use colored::Colorize; use colored::Colorize;
use dialoguer::{Confirm, Input, Select}; use dialoguer::{Confirm, Input, Select};

View File

@@ -1,4 +1,4 @@
use super::{AppConfig, GitProfile}; use super::{AppConfig, GitProfile, TokenConfig, TokenType};
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -75,12 +75,10 @@ impl ConfigManager {
bail!("Profile '{}' does not exist", name); bail!("Profile '{}' does not exist", name);
} }
// Check if it's the default profile
if self.config.default_profile.as_ref() == Some(&name.to_string()) { if self.config.default_profile.as_ref() == Some(&name.to_string()) {
self.config.default_profile = None; self.config.default_profile = None;
} }
// Remove from repo mappings
self.config.repo_profiles.retain(|_, v| v != name); self.config.repo_profiles.retain(|_, v| v != name);
self.config.profiles.remove(name); self.config.profiles.remove(name);
@@ -144,6 +142,56 @@ impl ConfigManager {
self.config.default_profile.as_ref() self.config.default_profile.as_ref()
} }
/// Record profile usage
pub fn record_profile_usage(&mut self, name: &str, repo_path: Option<String>) -> 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<Vec<&String>> {
self.config.profiles.get(profile_name).map(|p| p.tokens.keys().collect())
}
// Repository profile management // Repository profile management
/// Get profile for repository /// Get profile for repository
@@ -185,6 +233,13 @@ impl ConfigManager {
self.default_profile() self.default_profile()
} }
/// Check and compare profile with git configuration
pub fn check_profile_config(&self, profile_name: &str, repo: &git2::Repository) -> Result<super::ProfileComparison> {
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 // LLM configuration
/// Get LLM provider /// Get LLM provider

View File

@@ -8,7 +8,10 @@ pub mod manager;
pub mod profile; pub mod profile;
pub use manager::ConfigManager; pub use manager::ConfigManager;
pub use profile::{GitProfile, ProfileSettings}; pub use profile::{
GitProfile, ProfileSettings, SshConfig, GpgConfig, TokenConfig, TokenType,
UsageStats, ProfileComparison, ConfigDifference
};
/// Application configuration /// Application configuration
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]

View File

@@ -1,5 +1,6 @@
use anyhow::{bail, Result}; use anyhow::{bail, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Git profile containing user identity and authentication settings /// Git profile containing user identity and authentication settings
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -40,6 +41,14 @@ pub struct GitProfile {
/// Company/Organization name (for work profiles) /// Company/Organization name (for work profiles)
#[serde(default)] #[serde(default)]
pub organization: Option<String>, pub organization: Option<String>,
/// Personal Access Tokens (PATs) for different services
#[serde(default)]
pub tokens: HashMap<String, TokenConfig>,
/// Usage statistics
#[serde(default)]
pub usage: UsageStats,
} }
impl GitProfile { impl GitProfile {
@@ -56,6 +65,8 @@ impl GitProfile {
description: None, description: None,
is_work: false, is_work: false,
organization: None, organization: None,
tokens: HashMap::new(),
usage: UsageStats::default(),
} }
} }
@@ -84,6 +95,10 @@ impl GitProfile {
gpg.validate()?; gpg.validate()?;
} }
for token in self.tokens.values() {
token.validate()?;
}
Ok(()) Ok(())
} }
@@ -97,6 +112,11 @@ impl GitProfile {
self.gpg.is_some() || self.signing_key.is_some() 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) /// Get signing key (from GPG config or direct)
pub fn signing_key(&self) -> Option<&str> { pub fn signing_key(&self) -> Option<&str> {
self.signing_key self.signing_key
@@ -105,15 +125,44 @@ impl GitProfile {
.or_else(|| self.gpg.as_ref().map(|g| g.key_id.as_str())) .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<String>) {
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<()> { pub fn apply_to_repo(&self, repo: &git2::Repository) -> Result<()> {
let mut config = repo.config()?; let mut config = repo.config()?;
// Set user info
config.set_str("user.name", &self.user_name)?; config.set_str("user.name", &self.user_name)?;
config.set_str("user.email", &self.user_email)?; config.set_str("user.email", &self.user_email)?;
// Set signing key if available
if let Some(key) = self.signing_key() { if let Some(key) = self.signing_key() {
config.set_str("user.signingkey", 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 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", config.set_str("core.sshCommand",
@@ -150,6 +198,52 @@ impl GitProfile {
Ok(()) Ok(())
} }
/// Compare with current git configuration
pub fn compare_with_git_config(&self, repo: &git2::Repository) -> Result<ProfileComparison> {
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(|| "<not set>".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(|| "<not set>".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(|| "<not set>".to_string()),
});
}
}
Ok(comparison)
}
} }
/// Profile settings /// 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<String>,
/// Token type (personal, oauth, etc.)
#[serde(default)]
pub token_type: TokenType,
/// Token scopes/permissions
#[serde(default)]
pub scopes: Vec<String>,
/// Expiration date (RFC3339)
#[serde(default)]
pub expires_at: Option<String>,
/// Last used timestamp
#[serde(default)]
pub last_used: Option<String>,
/// Description
#[serde(default)]
pub description: Option<String>,
}
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<String>,
/// Repository-specific usage counts
#[serde(default)]
pub repo_usage: HashMap<String, u64>,
}
/// Comparison result between profile and git configuration
#[derive(Debug, Clone)]
pub struct ProfileComparison {
pub profile_name: String,
pub matches: bool,
pub differences: Vec<ConfigDifference>,
}
/// Configuration difference
#[derive(Debug, Clone)]
pub struct ConfigDifference {
pub key: String,
pub profile_value: String,
pub git_value: String,
}
fn default_gpg_program() -> String { fn default_gpg_program() -> String {
"gpg".to_string() "gpg".to_string()
} }
@@ -306,6 +516,7 @@ pub struct GitProfileBuilder {
description: Option<String>, description: Option<String>,
is_work: bool, is_work: bool,
organization: Option<String>, organization: Option<String>,
tokens: HashMap<String, TokenConfig>,
} }
impl GitProfileBuilder { impl GitProfileBuilder {
@@ -359,6 +570,11 @@ impl GitProfileBuilder {
self self
} }
pub fn token(mut self, service: impl Into<String>, token: TokenConfig) -> Self {
self.tokens.insert(service.into(), token);
self
}
pub fn build(self) -> Result<GitProfile> { pub fn build(self) -> Result<GitProfile> {
let name = self.name.ok_or_else(|| anyhow::anyhow!("Name is required"))?; 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"))?; let user_name = self.user_name.ok_or_else(|| anyhow::anyhow!("User name is required"))?;
@@ -375,6 +591,8 @@ impl GitProfileBuilder {
description: self.description, description: self.description,
is_work: self.is_work, is_work: self.is_work,
organization: self.organization, organization: self.organization,
tokens: self.tokens,
usage: UsageStats::default(),
}) })
} }
} }
@@ -409,4 +627,10 @@ mod tests {
assert!(profile.validate().is_err()); 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());
}
} }

View File

@@ -1,6 +1,7 @@
use anyhow::{bail, Context, Result}; 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::path::{Path, PathBuf};
use std::collections::HashMap;
pub mod changelog; pub mod changelog;
pub mod commit; pub mod commit;
@@ -10,36 +11,38 @@ pub use changelog::ChangelogGenerator;
pub use commit::CommitBuilder; pub use commit::CommitBuilder;
pub use tag::TagBuilder; pub use tag::TagBuilder;
/// Git repository wrapper /// Git repository wrapper with enhanced cross-platform support
pub struct GitRepo { pub struct GitRepo {
repo: Repository, repo: Repository,
path: std::path::PathBuf, path: PathBuf,
config: Option<Config>,
} }
impl GitRepo { impl GitRepo {
/// Open a git repository /// 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();
let absolute_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
if let Ok(repo) = Repository::discover(&path) { let repo = Repository::discover(&absolute_path)
return Ok(Self { .or_else(|_| Repository::open(&absolute_path))
repo, .with_context(|| {
path: path.to_path_buf() 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) { let config = repo.config().ok();
return Ok(Self { repo, path: path.to_path_buf() });
} Ok(Self {
repo,
// 如果依然失败,给出明确的错误提示 path: absolute_path,
bail!( config,
"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()
)
} }
/// Get repository path /// Get repository path
@@ -52,6 +55,89 @@ impl GitRepo {
&self.repo &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<Option<String>> {
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<HashMap<String, String>> {
Ok(HashMap::new())
}
/// Get the configured user name
pub fn get_user_name(&self) -> Result<String> {
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<String> {
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<Option<String>> {
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::<bool>().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::<bool>().ok())
.unwrap_or(false)
}
/// Get the GPG program to use
pub fn get_gpg_program(&self) -> Result<String> {
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<Signature> {
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 /// Check if this is a valid git repository
pub fn is_valid(&self) -> bool { pub fn is_valid(&self) -> bool {
!self.repo.is_bare() !self.repo.is_bare()
@@ -65,7 +151,7 @@ impl GitRepo {
.renames_head_to_index(true) .renames_head_to_index(true)
.renames_index_to_workdir(true), .renames_index_to_workdir(true),
))?; ))?;
Ok(!statuses.is_empty()) Ok(!statuses.is_empty())
} }
@@ -74,17 +160,17 @@ impl GitRepo {
let head = self.repo.head().ok(); let head = self.repo.head().ok();
let head_tree = head.as_ref() let head_tree = head.as_ref()
.and_then(|h| h.peel_to_tree().ok()); .and_then(|h| h.peel_to_tree().ok());
let mut index = self.repo.index()?; let mut index = self.repo.index()?;
let index_tree = index.write_tree()?; let index_tree = index.write_tree()?;
let index_tree = self.repo.find_tree(index_tree)?; let index_tree = self.repo.find_tree(index_tree)?;
let diff = if let Some(head) = head_tree { let diff = if let Some(head) = head_tree {
self.repo.diff_tree_to_index(Some(&head), Some(&index), None)? self.repo.diff_tree_to_index(Some(&head), Some(&index), None)?
} else { } else {
self.repo.diff_tree_to_index(None, Some(&index), None)? self.repo.diff_tree_to_index(None, Some(&index), None)?
}; };
let mut diff_text = String::new(); let mut diff_text = String::new();
diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| { diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
if let Ok(content) = std::str::from_utf8(line.content()) { if let Ok(content) = std::str::from_utf8(line.content()) {
@@ -92,14 +178,14 @@ impl GitRepo {
} }
true true
})?; })?;
Ok(diff_text) Ok(diff_text)
} }
/// Get unstaged diff /// Get unstaged diff
pub fn get_unstaged_diff(&self) -> Result<String> { pub fn get_unstaged_diff(&self) -> Result<String> {
let diff = self.repo.diff_index_to_workdir(None, None)?; let diff = self.repo.diff_index_to_workdir(None, None)?;
let mut diff_text = String::new(); let mut diff_text = String::new();
diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| { diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
if let Ok(content) = std::str::from_utf8(line.content()) { if let Ok(content) = std::str::from_utf8(line.content()) {
@@ -107,7 +193,7 @@ impl GitRepo {
} }
true true
})?; })?;
Ok(diff_text) Ok(diff_text)
} }
@@ -115,7 +201,7 @@ impl GitRepo {
pub fn get_full_diff(&self) -> Result<String> { pub fn get_full_diff(&self) -> Result<String> {
let staged = self.get_staged_diff().unwrap_or_default(); let staged = self.get_staged_diff().unwrap_or_default();
let unstaged = self.get_unstaged_diff().unwrap_or_default(); let unstaged = self.get_unstaged_diff().unwrap_or_default();
Ok(format!("{}{}", staged, unstaged)) Ok(format!("{}{}", staged, unstaged))
} }
@@ -127,14 +213,14 @@ impl GitRepo {
.renames_head_to_index(true) .renames_head_to_index(true)
.renames_index_to_workdir(true), .renames_index_to_workdir(true),
))?; ))?;
let mut files = vec![]; let mut files = vec![];
for entry in statuses.iter() { for entry in statuses.iter() {
if let Some(path) = entry.path() { if let Some(path) = entry.path() {
files.push(path.to_string()); files.push(path.to_string());
} }
} }
Ok(files) Ok(files)
} }
@@ -144,7 +230,7 @@ impl GitRepo {
StatusOptions::new() StatusOptions::new()
.include_untracked(false), .include_untracked(false),
))?; ))?;
let mut files = vec![]; let mut files = vec![];
for entry in statuses.iter() { for entry in statuses.iter() {
let status = entry.status(); let status = entry.status();
@@ -154,42 +240,52 @@ impl GitRepo {
} }
} }
} }
Ok(files) Ok(files)
} }
/// Stage files /// Stage files
pub fn stage_files<P: AsRef<Path>>(&self, paths: &[P]) -> Result<()> { pub fn stage_files<P: AsRef<Path>>(&self, paths: &[P]) -> Result<()> {
let mut index = self.repo.index()?; let mut index = self.repo.index()?;
for path in paths { 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()?; index.write()?;
Ok(()) Ok(())
} }
/// Stage all changes /// Stage all changes including subdirectories
pub fn stage_all(&self) -> Result<()> { pub fn stage_all(&self) -> Result<()> {
let mut index = self.repo.index()?; let mut index = self.repo.index()?;
// Get list of all files in working directory fn add_directory_recursive(index: &mut git2::Index, base_dir: &Path, current_dir: &Path) -> Result<()> {
let mut paths = Vec::new(); for entry in std::fs::read_dir(current_dir)
for entry in std::fs::read_dir(".")? { .with_context(|| format!("Failed to read directory: {:?}", current_dir))?
let entry = entry?; {
let path = entry.path(); let entry = entry?;
if path.is_file() { let path = entry.path();
paths.push(path.to_path_buf());
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 { add_directory_recursive(&mut index, &self.path, &self.path)?;
if let Ok(_) = index.add_path(&path_buf) {
// File added successfully
}
}
index.write()?; index.write()?;
Ok(()) Ok(())
} }
@@ -199,39 +295,50 @@ impl GitRepo {
let head = self.repo.head()?; let head = self.repo.head()?;
let head_commit = head.peel_to_commit()?; let head_commit = head.peel_to_commit()?;
let head_tree = head_commit.tree()?; let head_tree = head_commit.tree()?;
let mut index = self.repo.index()?; let mut index = self.repo.index()?;
for path in paths { for path in paths {
// For now, just reset the index to HEAD let path = path.as_ref();
// This removes all staged changes let rel_path = if path.is_absolute() {
index.clear()?; 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()?; index.write()?;
Ok(()) Ok(())
} }
/// Create a commit /// Create a commit
pub fn commit(&self, message: &str, sign: bool) -> Result<git2::Oid> { pub fn commit(&self, message: &str, sign: bool) -> Result<Oid> {
let signature = self.repo.signature()?; let signature = self.create_signature()?;
let head = self.repo.head().ok(); let head = self.repo.head().ok();
let parents = if let Some(ref head) = head { let parents = if let Some(ref head) = head {
vec![head.peel_to_commit()?] vec![head.peel_to_commit()?]
} else { } else {
vec![] vec![]
}; };
let parent_refs: Vec<&git2::Commit> = parents.iter().collect(); let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
let oid = if sign { let oid = if sign {
// For GPG signing, we need to use the command-line git self.commit_signed_with_git2(message, &signature)?
self.commit_signed(message, &signature)?
} else { } else {
let tree_id = self.repo.index()?.write_tree()?; let tree_id = self.repo.index()?.write_tree()?;
let tree = self.repo.find_tree(tree_id)?; let tree = self.repo.find_tree(tree_id)?;
self.repo.commit( self.repo.commit(
Some("HEAD"), Some("HEAD"),
&signature, &signature,
@@ -241,30 +348,30 @@ impl GitRepo {
&parent_refs, &parent_refs,
)? )?
}; };
Ok(oid) Ok(oid)
} }
/// Create a signed commit using git command /// Create a signed commit using git CLI
fn commit_signed(&self, message: &str, _signature: &git2::Signature) -> Result<git2::Oid> { fn commit_signed_with_git2(&self, message: &str, _signature: &Signature) -> Result<Oid> {
use std::process::Command;
// Write message to temp file
let temp_file = tempfile::NamedTempFile::new()?; let temp_file = tempfile::NamedTempFile::new()?;
std::fs::write(temp_file.path(), message)?; std::fs::write(temp_file.path(), message)?;
// Use git CLI for signed commit let mut cmd = std::process::Command::new("git");
let output = 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()]) .args(&["commit", "-S", "-F", temp_file.path().to_str().unwrap()])
.current_dir(&self.path) .current_dir(&self.path)
.output()?; .output()?;
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to create signed commit: {}", stderr); bail!("Failed to create signed commit: {}", stderr);
} }
// Get the new HEAD
let head = self.repo.head()?; let head = self.repo.head()?;
Ok(head.target().unwrap()) Ok(head.target().unwrap())
} }
@@ -272,7 +379,7 @@ impl GitRepo {
/// Get current branch name /// Get current branch name
pub fn current_branch(&self) -> Result<String> { pub fn current_branch(&self) -> Result<String> {
let head = self.repo.head()?; let head = self.repo.head()?;
if head.is_branch() { if head.is_branch() {
let name = head.shorthand() let name = head.shorthand()
.ok_or_else(|| anyhow::anyhow!("Invalid branch name"))?; .ok_or_else(|| anyhow::anyhow!("Invalid branch name"))?;
@@ -302,16 +409,16 @@ impl GitRepo {
pub fn get_commits(&self, count: usize) -> Result<Vec<CommitInfo>> { pub fn get_commits(&self, count: usize) -> Result<Vec<CommitInfo>> {
let mut revwalk = self.repo.revwalk()?; let mut revwalk = self.repo.revwalk()?;
revwalk.push_head()?; revwalk.push_head()?;
let mut commits = vec![]; let mut commits = vec![];
for (i, oid) in revwalk.enumerate() { for (i, oid) in revwalk.enumerate() {
if i >= count { if i >= count {
break; break;
} }
let oid = oid?; let oid = oid?;
let commit = self.repo.find_commit(oid)?; let commit = self.repo.find_commit(oid)?;
commits.push(CommitInfo { commits.push(CommitInfo {
id: oid.to_string(), id: oid.to_string(),
short_id: oid.to_string()[..8].to_string(), short_id: oid.to_string()[..8].to_string(),
@@ -321,7 +428,7 @@ impl GitRepo {
time: commit.time().seconds(), time: commit.time().seconds(),
}); });
} }
Ok(commits) Ok(commits)
} }
@@ -329,19 +436,19 @@ impl GitRepo {
pub fn get_commits_between(&self, from: &str, to: &str) -> Result<Vec<CommitInfo>> { pub fn get_commits_between(&self, from: &str, to: &str) -> Result<Vec<CommitInfo>> {
let from_obj = self.repo.revparse_single(from)?; let from_obj = self.repo.revparse_single(from)?;
let to_obj = self.repo.revparse_single(to)?; let to_obj = self.repo.revparse_single(to)?;
let from_commit = from_obj.peel_to_commit()?; let from_commit = from_obj.peel_to_commit()?;
let to_commit = to_obj.peel_to_commit()?; let to_commit = to_obj.peel_to_commit()?;
let mut revwalk = self.repo.revwalk()?; let mut revwalk = self.repo.revwalk()?;
revwalk.push(to_commit.id())?; revwalk.push(to_commit.id())?;
revwalk.hide(from_commit.id())?; revwalk.hide(from_commit.id())?;
let mut commits = vec![]; let mut commits = vec![];
for oid in revwalk { for oid in revwalk {
let oid = oid?; let oid = oid?;
let commit = self.repo.find_commit(oid)?; let commit = self.repo.find_commit(oid)?;
commits.push(CommitInfo { commits.push(CommitInfo {
id: oid.to_string(), id: oid.to_string(),
short_id: oid.to_string()[..8].to_string(), short_id: oid.to_string()[..8].to_string(),
@@ -351,18 +458,18 @@ impl GitRepo {
time: commit.time().seconds(), time: commit.time().seconds(),
}); });
} }
Ok(commits) Ok(commits)
} }
/// Get tags /// Get tags
pub fn get_tags(&self) -> Result<Vec<TagInfo>> { pub fn get_tags(&self) -> Result<Vec<TagInfo>> {
let mut tags = vec![]; let mut tags = vec![];
self.repo.tag_foreach(|oid, name| { self.repo.tag_foreach(|oid, name| {
let name = String::from_utf8_lossy(name); let name = String::from_utf8_lossy(name);
let name = name.strip_prefix("refs/tags/").unwrap_or(&name); let name = name.strip_prefix("refs/tags/").unwrap_or(&name);
if let Ok(commit) = self.repo.find_commit(oid) { if let Ok(commit) = self.repo.find_commit(oid) {
tags.push(TagInfo { tags.push(TagInfo {
name: name.to_string(), name: name.to_string(),
@@ -370,10 +477,10 @@ impl GitRepo {
message: commit.message().unwrap_or("").to_string(), message: commit.message().unwrap_or("").to_string(),
}); });
} }
true true
})?; })?;
Ok(tags) Ok(tags)
} }
@@ -381,14 +488,12 @@ impl GitRepo {
pub fn create_tag(&self, name: &str, message: Option<&str>, sign: bool) -> Result<()> { pub fn create_tag(&self, name: &str, message: Option<&str>, sign: bool) -> Result<()> {
let head = self.repo.head()?; let head = self.repo.head()?;
let target = head.peel_to_commit()?; let target = head.peel_to_commit()?;
if let Some(msg) = message { if let Some(msg) = message {
// Annotated tag let sig = self.create_signature()?;
let sig = self.repo.signature()?;
if sign { if sign {
// Use git CLI for signed tags self.create_signed_tag_with_git2(name, msg, &sig, target.id())?;
self.create_signed_tag(name, msg)?;
} else { } else {
self.repo.tag( self.repo.tag(
name, name,
@@ -399,36 +504,38 @@ impl GitRepo {
)?; )?;
} }
} else { } else {
// Lightweight tag
self.repo.tag( self.repo.tag(
name, name,
target.as_object(), target.as_object(),
&self.repo.signature()?, &self.create_signature()?,
"", "",
false, false,
)?; )?;
} }
Ok(()) Ok(())
} }
/// Create signed tag using git CLI /// Create signed tag using git CLI
fn create_signed_tag(&self, name: &str, message: &str) -> Result<()> { fn create_signed_tag_with_git2(&self, name: &str, message: &str, _signature: &Signature, _target_id: Oid) -> Result<()> {
use std::process::Command; let output = std::process::Command::new("git")
let output = Command::new("git")
.args(&["tag", "-s", name, "-m", message]) .args(&["tag", "-s", name, "-m", message])
.current_dir(&self.path) .current_dir(&self.path)
.output()?; .output()?;
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Failed to create signed tag: {}", stderr); bail!("Failed to create signed tag: {}", stderr);
} }
Ok(()) Ok(())
} }
/// Create GPG signature for arbitrary content
fn create_gpg_signature_for_content(&self, _content: &str, _gpg_program: &str, _signing_key: &str) -> Result<String> {
Ok(String::new())
}
/// Delete a tag /// Delete a tag
pub fn delete_tag(&self, name: &str) -> Result<()> { pub fn delete_tag(&self, name: &str) -> Result<()> {
self.repo.tag_delete(name)?; self.repo.tag_delete(name)?;
@@ -437,25 +544,23 @@ impl GitRepo {
/// Push to remote /// Push to remote
pub fn push(&self, remote: &str, refspec: &str) -> Result<()> { pub fn push(&self, remote: &str, refspec: &str) -> Result<()> {
use std::process::Command; let output = std::process::Command::new("git")
let output = Command::new("git")
.args(&["push", remote, refspec]) .args(&["push", remote, refspec])
.current_dir(&self.path) .current_dir(&self.path)
.output()?; .output()?;
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
bail!("Push failed: {}", stderr); bail!("Push failed: {}", stderr);
} }
Ok(()) Ok(())
} }
/// Get remote URL /// Get remote URL
pub fn get_remote_url(&self, remote: &str) -> Result<String> { pub fn get_remote_url(&self, remote: &str) -> Result<String> {
let remote = self.repo.find_remote(remote)?; let remote_obj = self.repo.find_remote(remote)?;
let url = remote.url() let url = remote_obj.url()
.ok_or_else(|| anyhow::anyhow!("Remote has no URL"))?; .ok_or_else(|| anyhow::anyhow!("Remote has no URL"))?;
Ok(url.to_string()) Ok(url.to_string())
} }
@@ -468,35 +573,35 @@ impl GitRepo {
/// Get repository status summary /// Get repository status summary
pub fn status_summary(&self) -> Result<StatusSummary> { pub fn status_summary(&self) -> Result<StatusSummary> {
let statuses = self.repo.statuses(Some(StatusOptions::new().include_untracked(true)))?; let statuses = self.repo.statuses(Some(StatusOptions::new().include_untracked(true)))?;
let mut staged = 0; let mut staged = 0;
let mut unstaged = 0; let mut unstaged = 0;
let mut untracked = 0; let mut untracked = 0;
let mut conflicted = 0; let mut conflicted = 0;
for entry in statuses.iter() { for entry in statuses.iter() {
let status = entry.status(); let status = entry.status();
if status.is_index_new() || status.is_index_modified() || if status.is_index_new() || status.is_index_modified() ||
status.is_index_deleted() || status.is_index_renamed() || status.is_index_deleted() || status.is_index_renamed() ||
status.is_index_typechange() { status.is_index_typechange() {
staged += 1; 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() { status.is_wt_renamed() || status.is_wt_typechange() {
unstaged += 1; unstaged += 1;
} }
if status.is_wt_new() { if status.is_wt_new() {
untracked += 1; untracked += 1;
} }
if status.is_conflicted() { if status.is_conflicted() {
conflicted += 1; conflicted += 1;
} }
} }
Ok(StatusSummary { Ok(StatusSummary {
staged, staged,
unstaged, unstaged,
@@ -602,3 +707,151 @@ pub fn find_repo<P: AsRef<Path>>(start_path: P) -> Result<GitRepo> {
pub fn is_git_repo<P: AsRef<Path>>(path: P) -> bool { pub fn is_git_repo<P: AsRef<Path>>(path: P) -> bool {
find_repo(path).is_ok() 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<Self> {
let _config = git2::Config::open_default()?;
Ok(Self {
repo: None,
global: true,
})
}
/// Get configuration value
pub fn get(&self, key: &str) -> Result<Option<String>> {
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<UserConfig> {
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<String>,
pub email: Option<String>,
pub signing_key: Option<String>,
pub ssh_command: Option<String>,
}
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<ConfigDiff> {
let mut diffs = vec![];
if self.name != other.name {
diffs.push(ConfigDiff {
key: "user.name".to_string(),
left: self.name.clone().unwrap_or_else(|| "<not set>".to_string()),
right: other.name.clone().unwrap_or_else(|| "<not set>".to_string()),
});
}
if self.email != other.email {
diffs.push(ConfigDiff {
key: "user.email".to_string(),
left: self.email.clone().unwrap_or_else(|| "<not set>".to_string()),
right: other.email.clone().unwrap_or_else(|| "<not set>".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(|| "<not set>".to_string()),
right: other.signing_key.clone().unwrap_or_else(|| "<not set>".to_string()),
});
}
diffs
}
}
/// Configuration difference
#[derive(Debug, Clone)]
pub struct ConfigDiff {
pub key: String,
pub left: String,
pub right: String,
}

View File

@@ -1,6 +1,6 @@
use anyhow::Result; use anyhow::Result;
use clap::{Parser, Subcommand, ValueEnum}; use clap::{Parser, Subcommand};
use tracing::{debug, info}; use tracing::debug;
mod commands; mod commands;
mod config; mod config;