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>(path: P) -> Result { 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 { 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 { 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, } impl GitRepo { pub fn open>(path: P) -> Result { 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> { 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> { Ok(HashMap::new()) } /// Get the configured user name pub fn get_user_name(&self) -> Result { 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 { 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> { 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::().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::().ok()) .unwrap_or(false) } /// Get the GPG program to use pub fn get_gpg_program(&self) -> Result { 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> { 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 { 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 { // 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 { 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 { 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 { 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> { 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> { 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>(&self, paths: &[P]) -> Result> { 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>(&self, path: P) -> Result { 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> { 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 = 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> { // 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>(&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 { 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 { 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 { 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 { 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 { 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> { 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> { 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> { 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 { 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 { 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 { 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 { Ok(!self.has_changes()?) } /// Get repository status summary pub fn status_summary(&self) -> Result { // 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 { 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>(start_path: P) -> Result { 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 \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>(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 { let _config = git2::Config::open_default()?; Ok(Self { repo: None, global: true, }) } /// Get configuration value pub fn get(&self, key: &str) -> Result> { 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 { 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, pub source: ConfigSource, pub local_value: Option, pub global_value: Option, } impl ConfigEntry { pub fn new(local: Option, global: Option) -> 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 { 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, pub email: Option, pub signing_key: Option, pub ssh_command: Option, } 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 { let mut diffs = vec![]; if self.name != other.name { diffs.push(ConfigDiff { key: "user.name".to_string(), left: self.name.clone().unwrap_or_else(|| "".to_string()), right: other .name .clone() .unwrap_or_else(|| "".to_string()), }); } if self.email != other.email { diffs.push(ConfigDiff { key: "user.email".to_string(), left: self .email .clone() .unwrap_or_else(|| "".to_string()), right: other .email .clone() .unwrap_or_else(|| "".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(|| "".to_string()), right: other .signing_key .clone() .unwrap_or_else(|| "".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" ); } }