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

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

View File

@@ -7,7 +7,7 @@ use crate::config::manager::ConfigManager;
use crate::config::CommitFormat;
use crate::generator::ContentGenerator;
use crate::git::{find_repo, GitRepo};
use crate::git::commit::{CommitBuilder, create_date_commit_message, parse_commit_message};
use crate::git::commit::{CommitBuilder, create_date_commit_message};
use crate::utils::validators::get_commit_types;
/// Generate and execute conventional commits

View File

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

View File

@@ -4,9 +4,9 @@ use colored::Colorize;
use dialoguer::{Confirm, Input, Select};
use crate::config::manager::ConfigManager;
use crate::config::{GitProfile};
use crate::config::{GitProfile, TokenConfig, TokenType, ProfileComparison};
use crate::config::profile::{GpgConfig, SshConfig};
use crate::git::find_repo;
use crate::git::{find_repo, GitConfigHelper, UserConfig};
use crate::utils::validators::validate_profile_name;
/// Manage Git profiles
@@ -75,6 +75,51 @@ enum ProfileSubcommand {
/// New profile name
to: String,
},
/// Manage tokens for a profile
Token {
#[command(subcommand)]
token_command: TokenSubcommand,
},
/// Check profile configuration against git
Check {
/// Profile name
name: Option<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 {
@@ -90,6 +135,9 @@ impl ProfileCommand {
Some(ProfileSubcommand::Apply { name, global }) => self.apply_profile(name.as_deref(), *global).await,
Some(ProfileSubcommand::Switch) => self.switch_profile().await,
Some(ProfileSubcommand::Copy { from, to }) => self.copy_profile(from, to).await,
Some(ProfileSubcommand::Token { token_command }) => self.handle_token_command(token_command).await,
Some(ProfileSubcommand::Check { name }) => self.check_profile(name.as_deref()).await,
Some(ProfileSubcommand::Stats { name }) => self.show_stats(name.as_deref()).await,
None => self.list_profiles().await,
}
}
@@ -148,7 +196,6 @@ impl ProfileCommand {
profile.is_work = is_work;
profile.organization = organization;
// SSH configuration
let setup_ssh = Confirm::new()
.with_prompt("Configure SSH key?")
.default(false)
@@ -158,7 +205,6 @@ impl ProfileCommand {
profile.ssh = Some(self.setup_ssh_interactive().await?);
}
// GPG configuration
let setup_gpg = Confirm::new()
.with_prompt("Configure GPG signing?")
.default(false)
@@ -168,12 +214,20 @@ impl ProfileCommand {
profile.gpg = Some(self.setup_gpg_interactive().await?);
}
let setup_token = Confirm::new()
.with_prompt("Add a Personal Access Token?")
.default(false)
.interact()?;
if setup_token {
self.setup_token_interactive(&mut profile).await?;
}
manager.add_profile(name.clone(), profile)?;
manager.save()?;
println!("{} Profile '{}' added successfully", "".green(), name.cyan());
// Offer to set as default
if manager.default_profile().is_none() {
let set_default = Confirm::new()
.with_prompt("Set as default profile?")
@@ -251,6 +305,13 @@ impl ProfileCommand {
if profile.has_gpg() {
println!(" {} GPG configured", "🔒".to_string().dimmed());
}
if profile.has_tokens() {
println!(" {} {} token(s)", "🔐".to_string().dimmed(), profile.tokens.len());
}
if let Some(ref usage) = profile.usage.last_used {
println!(" {} Last used: {}", "📊".to_string().dimmed(), usage.dimmed());
}
println!();
}
@@ -298,6 +359,24 @@ impl ProfileCommand {
println!(" Use agent: {}", if gpg.use_agent { "yes" } else { "no" });
}
if !profile.tokens.is_empty() {
println!("\n{}", "Tokens:".bold());
for (service, token) in &profile.tokens {
println!(" {} ({})", service.cyan(), token.token_type);
if let Some(ref desc) = token.description {
println!(" {}", desc);
}
}
}
if profile.usage.total_uses > 0 {
println!("\n{}", "Usage Statistics:".bold());
println!(" Total uses: {}", profile.usage.total_uses);
if let Some(ref last_used) = profile.usage.last_used {
println!(" Last used: {}", last_used);
}
}
Ok(())
}
@@ -330,6 +409,8 @@ impl ProfileCommand {
new_profile.organization = profile.organization;
new_profile.ssh = profile.ssh;
new_profile.gpg = profile.gpg;
new_profile.tokens = profile.tokens;
new_profile.usage = profile.usage;
manager.update_profile(name, new_profile)?;
manager.save()?;
@@ -365,18 +446,27 @@ impl ProfileCommand {
}
async fn apply_profile(&self, name: Option<&str>, global: bool) -> Result<()> {
let manager = ConfigManager::new()?;
let mut manager = ConfigManager::new()?;
let profile = if let Some(n) = name {
manager.get_profile(n)
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", n))?
.clone()
let profile_name = if let Some(n) = name {
n.to_string()
} else {
manager.default_profile()
manager.default_profile_name()
.ok_or_else(|| anyhow::anyhow!("No default profile set"))?
.clone()
};
let profile = manager.get_profile(&profile_name)
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", profile_name))?
.clone();
let repo_path = if global {
None
} else {
let repo = find_repo(std::env::current_dir()?.as_path())?;
Some(repo.path().to_string_lossy().to_string())
};
if global {
profile.apply_global()?;
println!("{} Applied profile '{}' globally", "".green(), profile.name.cyan());
@@ -386,6 +476,9 @@ impl ProfileCommand {
println!("{} Applied profile '{}' to current repository", "".green(), profile.name.cyan());
}
manager.record_profile_usage(&profile_name, repo_path)?;
manager.save()?;
Ok(())
}
@@ -419,7 +512,6 @@ impl ProfileCommand {
println!("{} Switched to profile '{}'", "".green(), selected.cyan());
// Offer to apply to current repo
if find_repo(".").is_ok() {
let apply = Confirm::new()
.with_prompt("Apply to current repository?")
@@ -445,6 +537,7 @@ impl ProfileCommand {
let mut new_profile = source.clone();
new_profile.name = to.to_string();
new_profile.usage = Default::default();
manager.add_profile(to.to_string(), new_profile)?;
manager.save()?;
@@ -454,6 +547,192 @@ impl ProfileCommand {
Ok(())
}
async fn handle_token_command(&self, cmd: &TokenSubcommand) -> Result<()> {
match cmd {
TokenSubcommand::Add { profile, service } => self.add_token(profile, service).await,
TokenSubcommand::Remove { profile, service } => self.remove_token(profile, service).await,
TokenSubcommand::List { profile } => self.list_tokens(profile).await,
}
}
async fn add_token(&self, profile_name: &str, service: &str) -> Result<()> {
let mut manager = ConfigManager::new()?;
if !manager.has_profile(profile_name) {
bail!("Profile '{}' not found", profile_name);
}
println!("{}", format!("\nAdd token to profile '{}'", profile_name).bold());
println!("{}", "".repeat(40));
let token_value: String = Input::new()
.with_prompt(&format!("Token for {}", service))
.interact_text()?;
let token_type_options = vec!["Personal", "OAuth", "Deploy", "App"];
let selection = Select::new()
.with_prompt("Token type")
.items(&token_type_options)
.default(0)
.interact()?;
let token_type = match selection {
0 => TokenType::Personal,
1 => TokenType::OAuth,
2 => TokenType::Deploy,
3 => TokenType::App,
_ => TokenType::Personal,
};
let description: String = Input::new()
.with_prompt("Description (optional)")
.allow_empty(true)
.interact_text()?;
let mut token = TokenConfig::new(token_value, token_type);
if !description.is_empty() {
token.description = Some(description);
}
manager.add_token_to_profile(profile_name, service.to_string(), token)?;
manager.save()?;
println!("{} Token for '{}' added to profile '{}'", "".green(), service.cyan(), profile_name);
Ok(())
}
async fn remove_token(&self, profile_name: &str, service: &str) -> Result<()> {
let mut manager = ConfigManager::new()?;
if !manager.has_profile(profile_name) {
bail!("Profile '{}' not found", profile_name);
}
let confirm = Confirm::new()
.with_prompt(&format!("Remove token '{}' from profile '{}'?", service, profile_name))
.default(false)
.interact()?;
if !confirm {
println!("{}", "Cancelled.".yellow());
return Ok(());
}
manager.remove_token_from_profile(profile_name, service)?;
manager.save()?;
println!("{} Token '{}' removed from profile '{}'", "".green(), service, profile_name);
Ok(())
}
async fn list_tokens(&self, profile_name: &str) -> Result<()> {
let manager = ConfigManager::new()?;
let profile = manager.get_profile(profile_name)
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", profile_name))?;
if profile.tokens.is_empty() {
println!("{} No tokens configured for profile '{}'", "".yellow(), profile_name);
return Ok(());
}
println!("{}", format!("\nTokens for profile '{}':", profile_name).bold());
println!("{}", "".repeat(40));
for (service, token) in &profile.tokens {
println!("{} ({})", service.cyan().bold(), token.token_type);
if let Some(ref desc) = token.description {
println!(" {}", desc);
}
if let Some(ref last_used) = token.last_used {
println!(" Last used: {}", last_used);
}
}
Ok(())
}
async fn check_profile(&self, name: Option<&str>) -> Result<()> {
let manager = ConfigManager::new()?;
let profile_name = if let Some(n) = name {
n.to_string()
} else {
manager.default_profile_name()
.ok_or_else(|| anyhow::anyhow!("No default profile set"))?
.clone()
};
let repo = find_repo(std::env::current_dir()?.as_path())?;
let comparison = manager.check_profile_config(&profile_name, repo.inner())?;
println!("{}", format!("\nChecking profile '{}' against git configuration", profile_name).bold());
println!("{}", "".repeat(60));
if comparison.matches {
println!("{} Profile configuration matches git settings", "".green().bold());
} else {
println!("{} Profile configuration differs from git settings", "".red().bold());
println!("\n{}", "Differences:".bold());
for diff in &comparison.differences {
println!("\n {}:", diff.key.cyan());
println!(" Profile: {}", diff.profile_value.green());
println!(" Git: {}", diff.git_value.yellow());
}
}
Ok(())
}
async fn show_stats(&self, name: Option<&str>) -> Result<()> {
let manager = ConfigManager::new()?;
if let Some(n) = name {
let profile = manager.get_profile(n)
.ok_or_else(|| anyhow::anyhow!("Profile '{}' not found", n))?;
self.show_single_profile_stats(profile);
} else {
let profiles = manager.list_profiles();
if profiles.is_empty() {
println!("{}", "No profiles configured.".yellow());
return Ok(());
}
println!("{}", "\nProfile Usage Statistics:".bold());
println!("{}", "".repeat(60));
for profile_name in profiles {
if let Some(profile) = manager.get_profile(profile_name) {
self.show_single_profile_stats(profile);
println!();
}
}
}
Ok(())
}
fn show_single_profile_stats(&self, profile: &GitProfile) {
println!("{}", format!("\n{}", profile.name).bold());
println!(" Total uses: {}", profile.usage.total_uses);
if let Some(ref last_used) = profile.usage.last_used {
println!(" Last used: {}", last_used);
}
if !profile.usage.repo_usage.is_empty() {
println!(" Repositories:");
for (repo, count) in &profile.usage.repo_usage {
println!(" {} ({} uses)", repo, count);
}
}
}
async fn setup_ssh_interactive(&self) -> Result<SshConfig> {
use std::path::PathBuf;
@@ -489,4 +768,19 @@ impl ProfileCommand {
use_agent: true,
})
}
async fn setup_token_interactive(&self, profile: &mut GitProfile) -> Result<()> {
let service: String = Input::new()
.with_prompt("Service name (e.g., github, gitlab)")
.interact_text()?;
let token_value: String = Input::new()
.with_prompt("Token value")
.interact_text()?;
let token = TokenConfig::new(token_value, TokenType::Personal);
profile.add_token(service, token);
Ok(())
}
}

View File

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