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

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

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