Files
QuiCommit/src/git/mod.rs
SidneyZhang 206bde0786 fix: 修复 tag/changelog 生成范围,增加 --auto 模式
- 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()
2026-07-24 17:41:18 +08:00

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"
);
}
}