- fix(get_tags): 使用 find_object+peel_to_commit 支持 annotated tag - fix(changelog): insert_changelog_entry 替换覆盖逻辑,保留已有章节 - feat: parse_changelog_versions 按 semver 降序提取版本 - feat: sort_tags_by_semver 取代纯时间排序 - feat(tag --auto): 优先读 Cargo.toml/pyproject.toml,回退 commit 分析 - feat(changelog): 无 --from 时自动检测 changelog 最高已有版本 - refactor: TagInfo::version_name(), GitRepo::find_tag_by_version()
1584 lines
48 KiB
Rust
1584 lines
48 KiB
Rust
use anyhow::{Context, Result, bail};
|
|
use git2::{Config, ObjectType, Oid, Repository, Signature, StatusOptions};
|
|
use std::collections::HashMap;
|
|
use std::path::{Component, Path, PathBuf};
|
|
|
|
pub mod changelog;
|
|
pub mod commit;
|
|
pub mod tag;
|
|
|
|
fn normalize_path_for_git2(path: &Path) -> PathBuf {
|
|
let mut normalized = path.to_path_buf();
|
|
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
let path_str = path.to_string_lossy();
|
|
if path_str.starts_with(r"\\?\")
|
|
&& let Some(stripped) = path_str.strip_prefix(r"\\?\")
|
|
{
|
|
normalized = PathBuf::from(stripped);
|
|
}
|
|
if path_str.starts_with(r"\\?\UNC\")
|
|
&& let Some(stripped) = path_str.strip_prefix(r"\\?\UNC\")
|
|
{
|
|
normalized = PathBuf::from(format!(r"\\{}", stripped));
|
|
}
|
|
}
|
|
|
|
normalized
|
|
}
|
|
|
|
fn get_absolute_path<P: AsRef<Path>>(path: P) -> Result<PathBuf> {
|
|
let path = path.as_ref();
|
|
|
|
if path.is_absolute() {
|
|
return Ok(normalize_path_for_git2(path));
|
|
}
|
|
|
|
let current_dir = std::env::current_dir().with_context(|| "Failed to get current directory")?;
|
|
|
|
let absolute = current_dir.join(path);
|
|
Ok(normalize_path_for_git2(&absolute))
|
|
}
|
|
|
|
fn resolve_path_without_canonicalize(path: &Path) -> PathBuf {
|
|
let mut components = Vec::new();
|
|
|
|
for component in path.components() {
|
|
match component {
|
|
Component::ParentDir => {
|
|
if !components.is_empty() && components.last() != Some(&Component::ParentDir) {
|
|
components.pop();
|
|
} else {
|
|
components.push(component);
|
|
}
|
|
}
|
|
Component::CurDir => {}
|
|
_ => components.push(component),
|
|
}
|
|
}
|
|
|
|
let mut result = PathBuf::new();
|
|
for component in components {
|
|
result.push(component.as_os_str());
|
|
}
|
|
|
|
normalize_path_for_git2(&result)
|
|
}
|
|
|
|
fn try_open_repo_with_git2(path: &Path) -> Result<Repository> {
|
|
let normalized = normalize_path_for_git2(path);
|
|
|
|
let discover_opts = git2::RepositoryOpenFlags::empty();
|
|
let ceiling_dirs: [&str; 0] = [];
|
|
|
|
let repo = Repository::open_ext(&normalized, discover_opts, ceiling_dirs)
|
|
.or_else(|_| Repository::discover(&normalized))
|
|
.or_else(|_| Repository::open(&normalized));
|
|
|
|
repo.map_err(|e| anyhow::anyhow!("git2 failed: {}", e))
|
|
}
|
|
|
|
fn try_open_repo_with_git_cli(path: &Path) -> Result<Repository> {
|
|
let output = std::process::Command::new("git")
|
|
.args(["rev-parse", "--show-toplevel"])
|
|
.current_dir(path)
|
|
.output()
|
|
.context("Failed to execute git command")?;
|
|
|
|
if !output.status.success() {
|
|
bail!("git CLI failed to find repository");
|
|
}
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let git_root = stdout.trim();
|
|
|
|
if git_root.is_empty() {
|
|
bail!("git CLI returned empty path");
|
|
}
|
|
|
|
let git_root_path = PathBuf::from(git_root);
|
|
let normalized = normalize_path_for_git2(&git_root_path);
|
|
|
|
Repository::open(&normalized)
|
|
.with_context(|| format!("Failed to open repo from git CLI path: {:?}", normalized))
|
|
}
|
|
|
|
fn diagnose_repo_issue(path: &Path) -> String {
|
|
let mut issues = Vec::new();
|
|
|
|
if !path.exists() {
|
|
issues.push(format!("Path does not exist: {:?}", path));
|
|
} else if !path.is_dir() {
|
|
issues.push(format!("Path is not a directory: {:?}", path));
|
|
}
|
|
|
|
let git_dir = path.join(".git");
|
|
if git_dir.exists() {
|
|
if git_dir.is_dir() {
|
|
issues.push("Found .git directory".to_string());
|
|
let config_file = git_dir.join("config");
|
|
if config_file.exists() {
|
|
issues.push("Git config file exists".to_string());
|
|
} else {
|
|
issues.push("WARNING: Git config file missing".to_string());
|
|
}
|
|
} else {
|
|
issues.push("Found .git file (submodule or worktree)".to_string());
|
|
}
|
|
} else {
|
|
issues.push("No .git found in current directory".to_string());
|
|
|
|
let mut current = path;
|
|
let mut depth = 0;
|
|
while let Some(parent) = current.parent() {
|
|
depth += 1;
|
|
if depth > 20 {
|
|
break;
|
|
}
|
|
let parent_git = parent.join(".git");
|
|
if parent_git.exists() {
|
|
issues.push(format!("Found .git in parent directory: {:?}", parent));
|
|
break;
|
|
}
|
|
current = parent;
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
let path_str = path.to_string_lossy();
|
|
if path_str.starts_with(r"\\?\") {
|
|
issues.push("Path has Windows extended-length prefix (\\\\?\\)".to_string());
|
|
}
|
|
if path_str.contains('\\') && path_str.contains('/') {
|
|
issues.push("WARNING: Path has mixed path separators".to_string());
|
|
}
|
|
}
|
|
|
|
if let Ok(current_dir) = std::env::current_dir() {
|
|
issues.push(format!("Current working directory: {:?}", current_dir));
|
|
}
|
|
|
|
issues.join("\n ")
|
|
}
|
|
|
|
pub struct GitRepo {
|
|
repo: Repository,
|
|
path: PathBuf,
|
|
config: Option<Config>,
|
|
}
|
|
|
|
impl GitRepo {
|
|
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
|
|
let path = path.as_ref();
|
|
|
|
let absolute_path = get_absolute_path(path)?;
|
|
let resolved_path = resolve_path_without_canonicalize(&absolute_path);
|
|
|
|
let repo = try_open_repo_with_git2(&resolved_path).or_else(|git2_err| {
|
|
try_open_repo_with_git_cli(&resolved_path).map_err(|cli_err| {
|
|
let diagnosis = diagnose_repo_issue(&resolved_path);
|
|
anyhow::anyhow!(
|
|
"Failed to open git repository:\n\
|
|
\n\
|
|
=== git2 Error ===\n {}\n\
|
|
\n\
|
|
=== git CLI Error ===\n {}\n\
|
|
\n\
|
|
=== Diagnosis ===\n {}\n\
|
|
\n\
|
|
=== Suggestions ===\n\
|
|
1. Ensure you are inside a git repository\n\
|
|
2. Run: git status (to verify git works)\n\
|
|
3. Run: git config --global --add safe.directory \"*\"\n\
|
|
4. Check file permissions",
|
|
git2_err,
|
|
cli_err,
|
|
diagnosis
|
|
)
|
|
})
|
|
})?;
|
|
|
|
let repo_path = repo
|
|
.workdir()
|
|
.map(|p| p.to_path_buf())
|
|
.unwrap_or_else(|| resolved_path.clone());
|
|
|
|
let config = repo.config().ok();
|
|
|
|
Ok(Self {
|
|
repo,
|
|
path: normalize_path_for_git2(&repo_path),
|
|
config,
|
|
})
|
|
}
|
|
|
|
/// Get repository path
|
|
pub fn path(&self) -> &Path {
|
|
&self.path
|
|
}
|
|
|
|
/// Get internal git2 repository
|
|
pub fn inner(&self) -> &Repository {
|
|
&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()
|
|
}
|
|
|
|
/// Check if there are uncommitted changes
|
|
pub fn has_changes(&self) -> Result<bool> {
|
|
let statuses = self.repo.statuses(Some(
|
|
StatusOptions::new()
|
|
.include_untracked(true)
|
|
.renames_head_to_index(true)
|
|
.renames_index_to_workdir(true),
|
|
))?;
|
|
|
|
Ok(!statuses.is_empty())
|
|
}
|
|
|
|
/// Get staged diff
|
|
pub fn get_staged_diff(&self) -> Result<String> {
|
|
// Use git CLI to get staged diff for better compatibility
|
|
let output = std::process::Command::new("git")
|
|
.args(["diff", "--cached"])
|
|
.current_dir(&self.path)
|
|
.output()
|
|
.with_context(|| "Failed to get staged diff with git command")?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
bail!("Failed to get staged diff: {}", stderr);
|
|
}
|
|
|
|
let diff_text = String::from_utf8_lossy(&output.stdout).to_string();
|
|
Ok(diff_text)
|
|
}
|
|
|
|
/// Get staged diff with files sorted by importance
|
|
/// Important files (source code) come first, then config files like Cargo.toml,
|
|
/// then lock files like Cargo.lock
|
|
pub fn get_staged_diff_sorted(&self) -> Result<String> {
|
|
let diff = self.get_staged_diff()?;
|
|
|
|
if diff.is_empty() {
|
|
return Ok(diff);
|
|
}
|
|
|
|
let mut file_diffs = Vec::new();
|
|
let mut current_file_diff = String::new();
|
|
let mut current_file = String::new();
|
|
|
|
for line in diff.lines() {
|
|
if line.starts_with("diff --git") {
|
|
// Save previous file diff if any
|
|
if !current_file_diff.is_empty() && !current_file.is_empty() {
|
|
file_diffs.push((current_file.clone(), current_file_diff.clone()));
|
|
}
|
|
current_file = extract_file_from_diff_line(line);
|
|
current_file_diff = format!("{}\n", line);
|
|
} else {
|
|
current_file_diff.push_str(line);
|
|
current_file_diff.push('\n');
|
|
}
|
|
}
|
|
|
|
// Add the last file diff
|
|
if !current_file_diff.is_empty() && !current_file.is_empty() {
|
|
file_diffs.push((current_file, current_file_diff));
|
|
}
|
|
|
|
// Sort by file importance
|
|
file_diffs.sort_by(|a, b| {
|
|
let score_a = file_importance_score(&a.0);
|
|
let score_b = file_importance_score(&b.0);
|
|
score_b.cmp(&score_a) // Descending order
|
|
});
|
|
|
|
// Combine sorted diffs
|
|
let sorted_diff: String = file_diffs.into_iter().map(|(_, diff)| diff).collect();
|
|
|
|
Ok(sorted_diff)
|
|
}
|
|
}
|
|
|
|
/// Extract filename from diff --git line
|
|
fn extract_file_from_diff_line(line: &str) -> String {
|
|
// Format: "diff --git a/path/to/file b/path/to/file"
|
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
|
if parts.len() >= 3 {
|
|
// Return the second path (after b/)
|
|
if let Some(path) = parts[2].strip_prefix("b/") {
|
|
return path.to_string();
|
|
}
|
|
// Fallback to first path (after a/)
|
|
if let Some(path) = parts[1].strip_prefix("a/") {
|
|
return path.to_string();
|
|
}
|
|
}
|
|
line.to_string()
|
|
}
|
|
|
|
/// Calculate file importance score
|
|
/// Higher score = more important
|
|
fn file_importance_score(filename: &str) -> i32 {
|
|
// Priority list for important file types
|
|
let important_extensions = [
|
|
".rs", ".py", ".js", ".ts", ".tsx", ".jsx", ".go", ".java", ".cpp", ".c", ".rust", ".vue",
|
|
".svelte", ".html", ".css", ".scss", ".sass", ".less",
|
|
];
|
|
|
|
// Config files that are important but less than source code
|
|
let config_files = [
|
|
"Cargo.toml",
|
|
"package.json",
|
|
"go.mod",
|
|
"go.sum",
|
|
"pom.xml",
|
|
"Makefile",
|
|
"CMakeLists.txt",
|
|
"build.gradle",
|
|
"gradle.properties",
|
|
];
|
|
|
|
// Lock files - lowest priority
|
|
let lock_files = [
|
|
"Cargo.lock",
|
|
"package-lock.json",
|
|
"yarn.lock",
|
|
"pnpm-lock.yaml",
|
|
"Gemfile.lock",
|
|
"composer.lock",
|
|
];
|
|
|
|
// Check lock files first (lowest priority)
|
|
for lock in lock_files.iter() {
|
|
if filename.ends_with(lock) {
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
// Check config files (medium priority)
|
|
for config in config_files.iter() {
|
|
if filename.ends_with(config) {
|
|
return 2;
|
|
}
|
|
}
|
|
|
|
// Check important source files (highest priority)
|
|
for ext in important_extensions.iter() {
|
|
if filename.ends_with(ext) {
|
|
return 3;
|
|
}
|
|
}
|
|
|
|
// Default priority for other files
|
|
2
|
|
}
|
|
|
|
impl GitRepo {
|
|
/// 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()) {
|
|
diff_text.push_str(content);
|
|
}
|
|
true
|
|
})?;
|
|
|
|
Ok(diff_text)
|
|
}
|
|
|
|
/// Get complete diff (staged + unstaged)
|
|
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))
|
|
}
|
|
|
|
/// Get list of changed files
|
|
pub fn get_changed_files(&self) -> Result<Vec<String>> {
|
|
let statuses = self.repo.statuses(Some(
|
|
StatusOptions::new()
|
|
.include_untracked(true)
|
|
.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)
|
|
}
|
|
|
|
/// Get list of staged files
|
|
pub fn get_staged_files(&self) -> Result<Vec<String>> {
|
|
let statuses = self
|
|
.repo
|
|
.statuses(Some(StatusOptions::new().include_untracked(false)))?;
|
|
|
|
let mut files = vec![];
|
|
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()
|
|
|| status.is_index_typechange())
|
|
&& let Some(path) = entry.path()
|
|
{
|
|
files.push(path.to_string());
|
|
}
|
|
}
|
|
|
|
Ok(files)
|
|
}
|
|
|
|
/// Stage files, skipping paths matched by .gitignore rules
|
|
/// Returns the list of skipped paths (those matched by .gitignore)
|
|
pub fn stage_files<P: AsRef<Path>>(&self, paths: &[P]) -> Result<Vec<String>> {
|
|
let mut index = self.repo.index()?;
|
|
let mut skipped = Vec::new();
|
|
|
|
for path in paths {
|
|
let path = path.as_ref();
|
|
let rel_path = if path.is_absolute() {
|
|
match path.strip_prefix(&self.path) {
|
|
Ok(p) => p,
|
|
Err(_) => {
|
|
// Outside repo, skip
|
|
skipped.push(path.to_string_lossy().to_string());
|
|
continue;
|
|
}
|
|
}
|
|
} else {
|
|
path
|
|
};
|
|
|
|
// Check if the path is ignored by .gitignore
|
|
if self.is_path_ignored(rel_path)? {
|
|
skipped.push(rel_path.to_string_lossy().to_string());
|
|
continue;
|
|
}
|
|
|
|
index.add_path(rel_path)?;
|
|
}
|
|
|
|
index.write()?;
|
|
Ok(skipped)
|
|
}
|
|
|
|
/// Check if a path is ignored by .gitignore rules
|
|
pub fn is_path_ignored<P: AsRef<Path>>(&self, path: P) -> Result<bool> {
|
|
let path = path.as_ref();
|
|
|
|
// Convert to relative path if absolute
|
|
let rel_path = if path.is_absolute() {
|
|
match path.strip_prefix(&self.path) {
|
|
Ok(p) => p,
|
|
Err(_) => return Ok(false), // Outside repo, not ignored
|
|
}
|
|
} else {
|
|
path
|
|
};
|
|
|
|
let path_str = match rel_path.to_str() {
|
|
Some(s) => s,
|
|
None => return Ok(false), // Non-UTF8 path, not ignored
|
|
};
|
|
|
|
let output = std::process::Command::new("git")
|
|
.args(["check-ignore", "--quiet", "--", path_str])
|
|
.current_dir(&self.path)
|
|
.output()?;
|
|
|
|
// Exit code 0: ignored, 1: not ignored, other: error
|
|
match output.status.code() {
|
|
Some(0) => Ok(true),
|
|
Some(1) => Ok(false),
|
|
_ => Ok(false), // Treat errors as not ignored
|
|
}
|
|
}
|
|
|
|
/// Remove files from index that are tracked but should be ignored by .gitignore
|
|
pub fn remove_ignored_from_index(&self) -> Result<Vec<String>> {
|
|
let output = std::process::Command::new("git")
|
|
.args(["ls-files", "--cached", "-i", "--exclude-standard"])
|
|
.current_dir(&self.path)
|
|
.output()?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
bail!("Failed to list ignored tracked files: {}", stderr);
|
|
}
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let ignored_files: Vec<String> = stdout
|
|
.lines()
|
|
.map(|l| l.trim().to_string())
|
|
.filter(|l| !l.is_empty())
|
|
.collect();
|
|
|
|
if ignored_files.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
// Remove these files from the index (keep working tree files)
|
|
let mut args = vec!["rm", "--cached", "--quiet", "--"];
|
|
for file in &ignored_files {
|
|
args.push(file);
|
|
}
|
|
|
|
let rm_output = std::process::Command::new("git")
|
|
.args(&args)
|
|
.current_dir(&self.path)
|
|
.output()?;
|
|
|
|
if !rm_output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&rm_output.stderr);
|
|
bail!("Failed to remove ignored files from index: {}", stderr);
|
|
}
|
|
|
|
Ok(ignored_files)
|
|
}
|
|
|
|
/// Stage all changes including subdirectories, then remove ignored tracked files
|
|
pub fn stage_all(&self) -> Result<Vec<String>> {
|
|
// Use git command for reliable staging (handles all edge cases)
|
|
let output = std::process::Command::new("git")
|
|
.args(["add", "-A"])
|
|
.current_dir(&self.path)
|
|
.output()
|
|
.with_context(|| "Failed to stage changes with git command")?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
bail!("Failed to stage changes: {}", stderr);
|
|
}
|
|
|
|
// Force refresh the git2 index to pick up changes from git CLI
|
|
let _ = self.repo.index()?.write();
|
|
|
|
// Remove files that are tracked but should be ignored by .gitignore
|
|
match self.remove_ignored_from_index() {
|
|
Ok(removed) => Ok(removed),
|
|
Err(e) => {
|
|
eprintln!("Warning: failed to clean ignored files from index: {}", e);
|
|
Ok(Vec::new())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Unstage files
|
|
pub fn unstage_files<P: AsRef<Path>>(&self, paths: &[P]) -> Result<()> {
|
|
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 {
|
|
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<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 {
|
|
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,
|
|
&signature,
|
|
message,
|
|
&tree,
|
|
&parent_refs,
|
|
)?
|
|
};
|
|
|
|
Ok(oid)
|
|
}
|
|
|
|
/// 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)?;
|
|
|
|
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 stdout = String::from_utf8_lossy(&output.stdout);
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
|
|
let error_msg = if stderr.is_empty() {
|
|
if stdout.is_empty() {
|
|
"GPG signing failed. Please check:\n\
|
|
1. GPG signing key is configured (git config --get user.signingkey)\n\
|
|
2. GPG agent is running\n\
|
|
3. You can sign commits manually (try: git commit -S -m 'test')"
|
|
.to_string()
|
|
} else {
|
|
stdout.to_string()
|
|
}
|
|
} else {
|
|
stderr.to_string()
|
|
};
|
|
|
|
bail!("Failed to create signed commit: {}", error_msg);
|
|
}
|
|
|
|
let head = self.repo.head()?;
|
|
Ok(head.target().unwrap())
|
|
}
|
|
|
|
/// 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"))?;
|
|
Ok(name.to_string())
|
|
} else {
|
|
bail!("HEAD is not pointing to a branch")
|
|
}
|
|
}
|
|
|
|
/// Get current commit hash (short)
|
|
pub fn current_commit_short(&self) -> Result<String> {
|
|
let head = self.repo.head()?;
|
|
let oid = head
|
|
.target()
|
|
.ok_or_else(|| anyhow::anyhow!("No target for HEAD"))?;
|
|
Ok(oid.to_string()[..8].to_string())
|
|
}
|
|
|
|
/// Get current commit hash (full)
|
|
pub fn current_commit(&self) -> Result<String> {
|
|
let head = self.repo.head()?;
|
|
let oid = head
|
|
.target()
|
|
.ok_or_else(|| anyhow::anyhow!("No target for HEAD"))?;
|
|
Ok(oid.to_string())
|
|
}
|
|
|
|
/// Get commit history
|
|
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(),
|
|
message: commit.message().unwrap_or("").to_string(),
|
|
author: commit.author().name().unwrap_or("").to_string(),
|
|
email: commit.author().email().unwrap_or("").to_string(),
|
|
time: commit.time().seconds(),
|
|
});
|
|
}
|
|
|
|
Ok(commits)
|
|
}
|
|
|
|
/// Get commits between two references
|
|
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(),
|
|
message: commit.message().unwrap_or("").to_string(),
|
|
author: commit.author().name().unwrap_or("").to_string(),
|
|
email: commit.author().email().unwrap_or("").to_string(),
|
|
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);
|
|
|
|
// Use find_object + peel_to_commit to handle both lightweight
|
|
// (oid is a commit) and annotated (oid is a tag object) tags.
|
|
if let Ok(obj) = self.repo.find_object(oid, None) {
|
|
if let Ok(commit) = obj.peel_to_commit() {
|
|
tags.push(TagInfo {
|
|
name: name.to_string(),
|
|
target: commit.id().to_string(),
|
|
message: commit.message().unwrap_or("").to_string(),
|
|
time: commit.time().seconds(),
|
|
});
|
|
}
|
|
}
|
|
|
|
true
|
|
})?;
|
|
|
|
// Sort tags by semver (descending), then by time
|
|
crate::git::tag::sort_tags_by_semver(&mut tags);
|
|
|
|
Ok(tags)
|
|
}
|
|
|
|
/// Find a tag whose version (stripping 'v' prefix) matches the given version string.
|
|
pub fn find_tag_by_version(&self, version: &str) -> Option<TagInfo> {
|
|
let tags = self.get_tags().ok()?;
|
|
tags.into_iter().find(|t| t.version_name() == version)
|
|
}
|
|
|
|
/// Create a tag
|
|
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 {
|
|
let sig = self.create_signature()?;
|
|
|
|
if sign {
|
|
self.create_signed_tag_with_git2(name, msg, &sig, target.id())?;
|
|
} else {
|
|
self.repo.tag(name, target.as_object(), &sig, msg, false)?;
|
|
}
|
|
} else {
|
|
self.repo.tag(
|
|
name,
|
|
target.as_object(),
|
|
&self.create_signature()?,
|
|
"",
|
|
false,
|
|
)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Create signed tag using git CLI
|
|
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)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Push to remote
|
|
pub fn push(&self, remote: &str, refspec: &str) -> Result<()> {
|
|
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_obj = self.repo.find_remote(remote)?;
|
|
let url = remote_obj
|
|
.url()
|
|
.ok_or_else(|| anyhow::anyhow!("Remote has no URL"))?;
|
|
Ok(url.to_string())
|
|
}
|
|
|
|
/// Check if working directory is clean
|
|
pub fn is_clean(&self) -> Result<bool> {
|
|
Ok(!self.has_changes()?)
|
|
}
|
|
|
|
/// Get repository status summary
|
|
pub fn status_summary(&self) -> Result<StatusSummary> {
|
|
// Use git CLI for more reliable status detection
|
|
let output = std::process::Command::new("git")
|
|
.args(["status", "--porcelain"])
|
|
.current_dir(&self.path)
|
|
.output()
|
|
.with_context(|| "Failed to get status with git command")?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
bail!("Failed to get status: {}", stderr);
|
|
}
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let mut staged = 0;
|
|
let mut unstaged = 0;
|
|
let mut untracked = 0;
|
|
let mut conflicted = 0;
|
|
|
|
for line in stdout.lines() {
|
|
if line.len() >= 2 {
|
|
let index_status = line.chars().next().unwrap();
|
|
let worktree_status = line.chars().nth(1).unwrap();
|
|
|
|
// Staged changes (first column not space)
|
|
if index_status != ' ' && index_status != '?' {
|
|
staged += 1;
|
|
}
|
|
|
|
// Unstaged changes (second column not space)
|
|
if worktree_status != ' ' && worktree_status != '?' {
|
|
unstaged += 1;
|
|
}
|
|
|
|
// Untracked files (both columns are ?)
|
|
if index_status == '?' && worktree_status == '?' {
|
|
untracked += 1;
|
|
}
|
|
|
|
// Conflicted files (both columns are U or DD, AA, etc.)
|
|
if (index_status == 'U' || worktree_status == 'U')
|
|
|| (index_status == 'A' && worktree_status == 'A')
|
|
|| (index_status == 'D' && worktree_status == 'D')
|
|
{
|
|
conflicted += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(StatusSummary {
|
|
staged,
|
|
unstaged,
|
|
untracked,
|
|
conflicted,
|
|
clean: staged == 0 && unstaged == 0 && untracked == 0 && conflicted == 0,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Commit information
|
|
#[derive(Debug, Clone)]
|
|
pub struct CommitInfo {
|
|
pub id: String,
|
|
pub short_id: String,
|
|
pub message: String,
|
|
pub author: String,
|
|
pub email: String,
|
|
pub time: i64,
|
|
}
|
|
|
|
impl CommitInfo {
|
|
/// Get commit message subject (first line)
|
|
pub fn subject(&self) -> &str {
|
|
self.message.lines().next().unwrap_or("")
|
|
}
|
|
|
|
/// Get commit type from conventional commit
|
|
pub fn commit_type(&self) -> Option<String> {
|
|
let subject = self.subject();
|
|
if let Some(colon) = subject.find(':') {
|
|
let type_part = &subject[..colon];
|
|
let type_name = type_part.split('(').next()?;
|
|
Some(type_name.to_string())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Tag information
|
|
#[derive(Debug, Clone)]
|
|
pub struct TagInfo {
|
|
pub name: String,
|
|
pub target: String,
|
|
pub message: String,
|
|
pub time: i64,
|
|
}
|
|
|
|
impl TagInfo {
|
|
/// Return the version portion of the tag name (stripping leading 'v' if present).
|
|
pub fn version_name(&self) -> &str {
|
|
self.name.strip_prefix('v').unwrap_or(&self.name)
|
|
}
|
|
}
|
|
|
|
/// Repository status summary
|
|
#[derive(Debug, Clone)]
|
|
pub struct StatusSummary {
|
|
pub staged: usize,
|
|
pub unstaged: usize,
|
|
pub untracked: usize,
|
|
pub conflicted: usize,
|
|
pub clean: bool,
|
|
}
|
|
|
|
impl StatusSummary {
|
|
/// Format as human-readable string
|
|
pub fn format(&self) -> String {
|
|
if self.clean {
|
|
"working tree clean".to_string()
|
|
} else {
|
|
let mut parts = vec![];
|
|
if self.staged > 0 {
|
|
parts.push(format!("{} staged", self.staged));
|
|
}
|
|
if self.unstaged > 0 {
|
|
parts.push(format!("{} unstaged", self.unstaged));
|
|
}
|
|
if self.untracked > 0 {
|
|
parts.push(format!("{} untracked", self.untracked));
|
|
}
|
|
if self.conflicted > 0 {
|
|
parts.push(format!("{} conflicted", self.conflicted));
|
|
}
|
|
parts.join(", ")
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn find_repo<P: AsRef<Path>>(start_path: P) -> Result<GitRepo> {
|
|
let start_path = start_path.as_ref();
|
|
|
|
let absolute_start = get_absolute_path(start_path)?;
|
|
let resolved_start = resolve_path_without_canonicalize(&absolute_start);
|
|
|
|
if let Ok(repo) = GitRepo::open(&resolved_start) {
|
|
return Ok(repo);
|
|
}
|
|
|
|
let mut current = resolved_start.as_path();
|
|
let mut attempted_paths = vec![current.to_string_lossy().to_string()];
|
|
|
|
let max_depth = 50;
|
|
let mut depth = 0;
|
|
|
|
while let Some(parent) = current.parent() {
|
|
depth += 1;
|
|
if depth > max_depth {
|
|
break;
|
|
}
|
|
|
|
attempted_paths.push(parent.to_string_lossy().to_string());
|
|
|
|
if let Ok(repo) = GitRepo::open(parent) {
|
|
return Ok(repo);
|
|
}
|
|
current = parent;
|
|
}
|
|
|
|
if let Ok(output) = std::process::Command::new("git")
|
|
.args(["rev-parse", "--show-toplevel"])
|
|
.current_dir(&resolved_start)
|
|
.output()
|
|
&& output.status.success()
|
|
{
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
let git_root = stdout.trim();
|
|
if !git_root.is_empty()
|
|
&& let Ok(repo) = GitRepo::open(git_root)
|
|
{
|
|
return Ok(repo);
|
|
}
|
|
}
|
|
|
|
let diagnosis = diagnose_repo_issue(&resolved_start);
|
|
|
|
bail!(
|
|
"No git repository found.\n\
|
|
\n\
|
|
=== Starting Path ===\n {:?}\n\
|
|
\n\
|
|
=== Paths Attempted ===\n {}\n\
|
|
\n\
|
|
=== Current Directory ===\n {:?}\n\
|
|
\n\
|
|
=== Diagnosis ===\n {}\n\
|
|
\n\
|
|
=== Suggestions ===\n\
|
|
1. Ensure you are inside a git repository (run: git status)\n\
|
|
2. Initialize a new repo: git init\n\
|
|
3. Clone an existing repo: git clone <url>\n\
|
|
4. Check if .git directory exists and is accessible",
|
|
resolved_start,
|
|
attempted_paths.join("\n "),
|
|
std::env::current_dir().unwrap_or_default(),
|
|
diagnosis
|
|
)
|
|
}
|
|
|
|
/// Check if path is inside a git repository
|
|
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(())
|
|
}
|
|
}
|
|
|
|
/// Configuration source indicator
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub enum ConfigSource {
|
|
Local,
|
|
Global,
|
|
NotSet,
|
|
}
|
|
|
|
impl std::fmt::Display for ConfigSource {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
ConfigSource::Local => write!(f, "local"),
|
|
ConfigSource::Global => write!(f, "global"),
|
|
ConfigSource::NotSet => write!(f, "not set"),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Single configuration entry with source information
|
|
#[derive(Debug, Clone)]
|
|
pub struct ConfigEntry {
|
|
pub value: Option<String>,
|
|
pub source: ConfigSource,
|
|
pub local_value: Option<String>,
|
|
pub global_value: Option<String>,
|
|
}
|
|
|
|
impl ConfigEntry {
|
|
pub fn new(local: Option<String>, global: Option<String>) -> Self {
|
|
let (value, source) = match (&local, &global) {
|
|
(Some(_), _) => (local.clone(), ConfigSource::Local),
|
|
(None, Some(_)) => (global.clone(), ConfigSource::Global),
|
|
(None, None) => (None, ConfigSource::NotSet),
|
|
};
|
|
Self {
|
|
value,
|
|
source,
|
|
local_value: local,
|
|
global_value: global,
|
|
}
|
|
}
|
|
|
|
pub fn is_set(&self) -> bool {
|
|
self.value.is_some()
|
|
}
|
|
|
|
pub fn is_local(&self) -> bool {
|
|
self.source == ConfigSource::Local
|
|
}
|
|
|
|
pub fn is_global(&self) -> bool {
|
|
self.source == ConfigSource::Global
|
|
}
|
|
}
|
|
|
|
/// Merged user configuration with local/global source tracking
|
|
#[derive(Debug, Clone)]
|
|
pub struct MergedUserConfig {
|
|
pub name: ConfigEntry,
|
|
pub email: ConfigEntry,
|
|
pub signing_key: ConfigEntry,
|
|
pub ssh_command: ConfigEntry,
|
|
pub commit_gpgsign: ConfigEntry,
|
|
pub tag_gpgsign: ConfigEntry,
|
|
}
|
|
|
|
impl MergedUserConfig {
|
|
pub fn from_repo(repo: &Repository) -> Result<Self> {
|
|
let local_config = repo.config().ok();
|
|
let global_config = git2::Config::open_default().ok();
|
|
|
|
let get_entry = |key: &str| -> ConfigEntry {
|
|
let local = local_config.as_ref().and_then(|c| c.get_string(key).ok());
|
|
let global = global_config.as_ref().and_then(|c| c.get_string(key).ok());
|
|
ConfigEntry::new(local, global)
|
|
};
|
|
|
|
Ok(Self {
|
|
name: get_entry("user.name"),
|
|
email: get_entry("user.email"),
|
|
signing_key: get_entry("user.signingkey"),
|
|
ssh_command: get_entry("core.sshCommand"),
|
|
commit_gpgsign: get_entry("commit.gpgsign"),
|
|
tag_gpgsign: get_entry("tag.gpgsign"),
|
|
})
|
|
}
|
|
|
|
pub fn is_complete(&self) -> bool {
|
|
self.name.is_set() && self.email.is_set()
|
|
}
|
|
|
|
pub fn has_local_overrides(&self) -> bool {
|
|
self.name.is_local()
|
|
|| self.email.is_local()
|
|
|| self.signing_key.is_local()
|
|
|| self.ssh_command.is_local()
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use git2::Signature;
|
|
use tempfile::TempDir;
|
|
|
|
fn init_test_repo() -> (TempDir, GitRepo) {
|
|
let dir = TempDir::new().unwrap();
|
|
let repo = git2::Repository::init(dir.path()).unwrap();
|
|
|
|
// Configure git user
|
|
let mut config = repo.config().unwrap();
|
|
config.set_str("user.name", "Test User").unwrap();
|
|
config.set_str("user.email", "test@example.com").unwrap();
|
|
|
|
// Create an initial commit (needed for tags)
|
|
let sig = Signature::now("Test User", "test@example.com").unwrap();
|
|
let tree_id = {
|
|
let mut index = repo.index().unwrap();
|
|
index.write_tree().unwrap()
|
|
};
|
|
let tree = repo.find_tree(tree_id).unwrap();
|
|
repo.commit(Some("HEAD"), &sig, &sig, "initial commit", &tree, &[])
|
|
.unwrap();
|
|
|
|
let git_repo = GitRepo::open(dir.path()).unwrap();
|
|
(dir, git_repo)
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_tags_returns_annotated_tags() {
|
|
let (_dir, repo) = init_test_repo();
|
|
let sig = Signature::now("Test User", "test@example.com").unwrap();
|
|
let head = repo.repo.head().unwrap().peel_to_commit().unwrap();
|
|
|
|
// Create an annotated tag (what QuiCommit creates by default)
|
|
repo.repo
|
|
.tag("v0.2.0", head.as_object(), &sig, "Release v0.2.0", false)
|
|
.unwrap();
|
|
|
|
let tags = repo.get_tags().unwrap();
|
|
let tag_names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
|
|
|
|
assert!(
|
|
tag_names.contains(&"v0.2.0"),
|
|
"annotated tag v0.2.0 should appear in tags list, got: {:?}",
|
|
tag_names
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_tags_returns_lightweight_tags() {
|
|
let (_dir, repo) = init_test_repo();
|
|
let head = repo.repo.head().unwrap().peel_to_commit().unwrap();
|
|
|
|
// Create a lightweight tag
|
|
repo.repo.tag("v0.1.0", head.as_object(), &repo.repo.signature().unwrap(), "", false)
|
|
.unwrap();
|
|
|
|
let tags = repo.get_tags().unwrap();
|
|
let tag_names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
|
|
|
|
assert!(
|
|
tag_names.contains(&"v0.1.0"),
|
|
"lightweight tag v0.1.0 should appear in tags list, got: {:?}",
|
|
tag_names
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_tags_returns_mixed_annotated_and_lightweight() {
|
|
let (_dir, repo) = init_test_repo();
|
|
let sig = Signature::now("Test User", "test@example.com").unwrap();
|
|
let head = repo.repo.head().unwrap().peel_to_commit().unwrap();
|
|
|
|
// Create annotated tag
|
|
repo.repo
|
|
.tag("v0.2.0", head.as_object(), &sig, "annotated", false)
|
|
.unwrap();
|
|
|
|
// Create lightweight tag
|
|
repo.repo
|
|
.tag("v0.1.0", head.as_object(), &repo.repo.signature().unwrap(), "", false)
|
|
.unwrap();
|
|
|
|
let tags = repo.get_tags().unwrap();
|
|
let tag_names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
|
|
|
|
assert!(
|
|
tag_names.contains(&"v0.2.0"),
|
|
"annotated tag should be present, got: {:?}",
|
|
tag_names
|
|
);
|
|
assert!(
|
|
tag_names.contains(&"v0.1.0"),
|
|
"lightweight tag should be present, got: {:?}",
|
|
tag_names
|
|
);
|
|
assert_eq!(tags.len(), 2, "should have exactly 2 tags");
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_tags_target_points_to_commit_not_tag_object() {
|
|
let (_dir, repo) = init_test_repo();
|
|
let sig = Signature::now("Test User", "test@example.com").unwrap();
|
|
let head = repo.repo.head().unwrap().peel_to_commit().unwrap();
|
|
let expected_commit_id = head.id().to_string();
|
|
|
|
// Create annotated tag
|
|
repo.repo
|
|
.tag("v0.2.0", head.as_object(), &sig, "annotated", false)
|
|
.unwrap();
|
|
|
|
let tags = repo.get_tags().unwrap();
|
|
let tag = tags.iter().find(|t| t.name == "v0.2.0").unwrap();
|
|
|
|
assert_eq!(
|
|
tag.target, expected_commit_id,
|
|
"tag.target should be the commit OID, not the tag object OID"
|
|
);
|
|
}
|
|
}
|