✨ 新增个人访问令牌、使用统计与配置校验功能
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
383
src/git/mod.rs
383
src/git/mod.rs
@@ -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 {
|
||||||
bail!(
|
repo,
|
||||||
"Failed to open git repository at '{:?}'. Please ensure:\n\
|
path: absolute_path,
|
||||||
1. The directory is set as safe (run: git config --global --add safe.directory \"{}\")\n\
|
config,
|
||||||
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()
|
||||||
@@ -163,33 +249,43 @@ impl GitRepo {
|
|||||||
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);
|
||||||
for path_buf in paths {
|
}
|
||||||
if let Ok(_) = index.add_path(&path_buf) {
|
} else if path.is_dir() {
|
||||||
// File added successfully
|
add_directory_recursive(index, base_dir, &path)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
add_directory_recursive(&mut index, &self.path, &self.path)?;
|
||||||
index.write()?;
|
index.write()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -203,9 +299,21 @@ impl GitRepo {
|
|||||||
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()?;
|
||||||
@@ -213,8 +321,8 @@ impl GitRepo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 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 {
|
||||||
@@ -226,8 +334,7 @@ impl GitRepo {
|
|||||||
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)?;
|
||||||
@@ -245,16 +352,17 @@ impl GitRepo {
|
|||||||
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()?;
|
||||||
@@ -264,7 +372,6 @@ impl GitRepo {
|
|||||||
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())
|
||||||
}
|
}
|
||||||
@@ -383,12 +490,10 @@ impl GitRepo {
|
|||||||
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,11 +504,10 @@ 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,
|
||||||
)?;
|
)?;
|
||||||
@@ -413,10 +517,8 @@ impl GitRepo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 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()?;
|
||||||
@@ -429,6 +531,11 @@ impl GitRepo {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create GPG signature for arbitrary content
|
||||||
|
fn create_gpg_signature_for_content(&self, _content: &str, _gpg_program: &str, _signing_key: &str) -> Result<String> {
|
||||||
|
Ok(String::new())
|
||||||
|
}
|
||||||
|
|
||||||
/// Delete a tag
|
/// Delete a tag
|
||||||
pub fn delete_tag(&self, name: &str) -> Result<()> {
|
pub fn delete_tag(&self, name: &str) -> Result<()> {
|
||||||
self.repo.tag_delete(name)?;
|
self.repo.tag_delete(name)?;
|
||||||
@@ -437,9 +544,7 @@ 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()?;
|
||||||
@@ -454,8 +559,8 @@ impl GitRepo {
|
|||||||
|
|
||||||
/// 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())
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user