//! Git integration module for detecting repository state and validating git commands. //! //! This module provides functionality to: //! - Detect if the current directory is a git repository //! - Capture git repository state (branch, status, uncommitted changes) //! - Validate git commands for safety (read-only vs destructive operations) use color_eyre::eyre::Result; use std::path::Path; use std::process::Command; /// Status of a file in the git working tree #[derive(Debug, Clone, PartialEq, Eq)] pub enum GitFileStatus { /// File has been modified Modified { path: String }, /// File has been added (staged) Added { path: String }, /// File has been deleted Deleted { path: String }, /// File has been renamed Renamed { from: String, to: String }, /// File is untracked Untracked { path: String }, } impl GitFileStatus { /// Get the primary path associated with this status pub fn path(&self) -> &str { match self { Self::Modified { path } => path, Self::Added { path } => path, Self::Deleted { path } => path, Self::Renamed { to, .. } => to, Self::Untracked { path } => path, } } } /// Complete state of a git repository #[derive(Debug, Clone)] pub struct GitState { /// Whether the current directory is in a git repository pub is_git_repo: bool, /// Current branch name (None if not in a repo or detached HEAD) pub current_branch: Option, /// Main branch name (main/master, None if not detected) pub main_branch: Option, /// Status of files in the working tree pub status: Vec, /// Whether there are any uncommitted changes pub has_uncommitted_changes: bool, /// Remote URL for the repository (None if no remote configured) pub remote_url: Option, } impl GitState { /// Create a default GitState for non-git directories pub fn not_a_repo() -> Self { Self { is_git_repo: false, current_branch: None, main_branch: None, status: Vec::new(), has_uncommitted_changes: false, remote_url: None, } } } /// Detect the current git repository state /// /// This function runs various git commands to gather information about the repository. /// If git is not available or the directory is not a git repo, returns a default state. pub fn detect_git_state(working_dir: &Path) -> Result { // Check if this is a git repository let is_repo = Command::new("git") .arg("rev-parse") .arg("--git-dir") .current_dir(working_dir) .output() .map(|output| output.status.success()) .unwrap_or(false); if !is_repo { return Ok(GitState::not_a_repo()); } // Get current branch let current_branch = get_current_branch(working_dir)?; // Detect main branch (try main first, then master) let main_branch = detect_main_branch(working_dir)?; // Get file status let status = get_git_status(working_dir)?; // Check if there are uncommitted changes let has_uncommitted_changes = !status.is_empty(); // Get remote URL let remote_url = get_remote_url(working_dir)?; Ok(GitState { is_git_repo: true, current_branch, main_branch, status, has_uncommitted_changes, remote_url, }) } /// Get the current branch name fn get_current_branch(working_dir: &Path) -> Result> { let output = Command::new("git") .arg("rev-parse") .arg("--abbrev-ref") .arg("HEAD") .current_dir(working_dir) .output()?; if !output.status.success() { return Ok(None); } let branch = String::from_utf8_lossy(&output.stdout).trim().to_string(); // "HEAD" means detached HEAD state if branch == "HEAD" { Ok(None) } else { Ok(Some(branch)) } } /// Detect the main branch (main or master) fn detect_main_branch(working_dir: &Path) -> Result> { // Try to get all branches let output = Command::new("git") .arg("branch") .arg("-a") .current_dir(working_dir) .output()?; if !output.status.success() { return Ok(None); } let branches = String::from_utf8_lossy(&output.stdout); // Check for main branch first (modern convention) if branches.lines().any(|line| { let trimmed = line.trim_start_matches('*').trim(); trimmed == "main" || trimmed.ends_with("/main") }) { return Ok(Some("main".to_string())); } // Fall back to master if branches.lines().any(|line| { let trimmed = line.trim_start_matches('*').trim(); trimmed == "master" || trimmed.ends_with("/master") }) { return Ok(Some("master".to_string())); } Ok(None) } /// Get the git status for all files fn get_git_status(working_dir: &Path) -> Result> { let output = Command::new("git") .arg("status") .arg("--porcelain") .arg("-z") // Null-terminated for better parsing .current_dir(working_dir) .output()?; if !output.status.success() { return Ok(Vec::new()); } let status_text = String::from_utf8_lossy(&output.stdout); let mut statuses = Vec::new(); // Parse porcelain format with null termination // Format: XY filename\0 (where X is staged status, Y is unstaged status) for entry in status_text.split('\0').filter(|s| !s.is_empty()) { if entry.len() < 3 { continue; } let status_code = &entry[0..2]; let path = entry[3..].to_string(); // Parse status codes match status_code { "M " | " M" | "MM" => { statuses.push(GitFileStatus::Modified { path }); } "A " | " A" | "AM" => { statuses.push(GitFileStatus::Added { path }); } "D " | " D" | "AD" => { statuses.push(GitFileStatus::Deleted { path }); } "??" => { statuses.push(GitFileStatus::Untracked { path }); } s if s.starts_with('R') => { // Renamed files have format "R old_name -> new_name" if let Some((from, to)) = path.split_once(" -> ") { statuses.push(GitFileStatus::Renamed { from: from.to_string(), to: to.to_string(), }); } else { // Fallback if parsing fails statuses.push(GitFileStatus::Modified { path }); } } _ => { // Unknown status code, treat as modified statuses.push(GitFileStatus::Modified { path }); } } } Ok(statuses) } /// Get the remote URL for the repository fn get_remote_url(working_dir: &Path) -> Result> { let output = Command::new("git") .arg("remote") .arg("get-url") .arg("origin") .current_dir(working_dir) .output()?; if !output.status.success() { return Ok(None); } let url = String::from_utf8_lossy(&output.stdout).trim().to_string(); if url.is_empty() { Ok(None) } else { Ok(Some(url)) } } /// Check if a git command is safe (read-only) /// /// Safe commands include: /// - status, log, show, diff, branch (without -D) /// - remote (without add/remove) /// - config --get /// - rev-parse, ls-files, ls-tree pub fn is_safe_git_command(command: &str) -> bool { let parts: Vec<&str> = command.split_whitespace().collect(); if parts.is_empty() || parts[0] != "git" { return false; } if parts.len() < 2 { return false; } let subcommand = parts[1]; // List of read-only git commands match subcommand { "status" | "log" | "show" | "diff" | "blame" | "reflog" => true, "ls-files" | "ls-tree" | "ls-remote" => true, "rev-parse" | "rev-list" => true, "describe" | "tag" if !command.contains("-d") && !command.contains("--delete") => true, "branch" if !command.contains("-D") && !command.contains("-d") && !command.contains("-m") => true, "remote" if command.contains("get-url") || command.contains("-v") || command.contains("show") => true, "config" if command.contains("--get") || command.contains("--list") => true, "grep" | "shortlog" | "whatchanged" => true, "fetch" if !command.contains("--prune") => true, _ => false, } } /// Check if a git command is destructive /// /// Returns (is_destructive, warning_message) tuple. /// Destructive commands include: /// - push --force, reset --hard, clean -fd /// - rebase, amend, filter-branch /// - branch -D, tag -d pub fn is_destructive_git_command(command: &str) -> (bool, &'static str) { let cmd_lower = command.to_lowercase(); // Check for force push if cmd_lower.contains("push") && (cmd_lower.contains("--force") || cmd_lower.contains("-f")) { return (true, "Force push can overwrite remote history and affect other collaborators"); } // Check for hard reset if cmd_lower.contains("reset") && cmd_lower.contains("--hard") { return (true, "Hard reset will discard uncommitted changes permanently"); } // Check for git clean if cmd_lower.contains("clean") && (cmd_lower.contains("-f") || cmd_lower.contains("-d")) { return (true, "Git clean will permanently delete untracked files"); } // Check for rebase if cmd_lower.contains("rebase") { return (true, "Rebase rewrites commit history and can cause conflicts"); } // Check for amend if cmd_lower.contains("commit") && cmd_lower.contains("--amend") { return (true, "Amending rewrites the last commit and changes its hash"); } // Check for filter-branch or filter-repo if cmd_lower.contains("filter-branch") || cmd_lower.contains("filter-repo") { return (true, "Filter operations rewrite repository history"); } // Check for branch/tag deletion if (cmd_lower.contains("branch") && (cmd_lower.contains("-D") || cmd_lower.contains("-d"))) || (cmd_lower.contains("tag") && (cmd_lower.contains("-d") || cmd_lower.contains("--delete"))) { return (true, "This will delete a branch or tag"); } // Check for reflog expire if cmd_lower.contains("reflog") && cmd_lower.contains("expire") { return (true, "Expiring reflog removes recovery points for lost commits"); } // Check for gc with aggressive or prune if cmd_lower.contains("gc") && (cmd_lower.contains("--aggressive") || cmd_lower.contains("--prune")) { return (true, "Aggressive garbage collection can make recovery difficult"); } (false, "") } /// Format git state for human-readable display /// /// Example output: /// ```text /// Git Repository: yes /// Current branch: feature-branch /// Main branch: main /// Status: 3 modified, 1 untracked /// Remote: https://github.com/user/repo.git /// ``` pub fn format_git_status(state: &GitState) -> String { if !state.is_git_repo { return "Not a git repository".to_string(); } let mut lines = Vec::new(); lines.push("Git Repository: yes".to_string()); if let Some(branch) = &state.current_branch { lines.push(format!("Current branch: {}", branch)); } else { lines.push("Current branch: (detached HEAD)".to_string()); } if let Some(main) = &state.main_branch { lines.push(format!("Main branch: {}", main)); } // Summarize status if state.status.is_empty() { lines.push("Status: clean working tree".to_string()); } else { let mut modified = 0; let mut added = 0; let mut deleted = 0; let mut renamed = 0; let mut untracked = 0; for status in &state.status { match status { GitFileStatus::Modified { .. } => modified += 1, GitFileStatus::Added { .. } => added += 1, GitFileStatus::Deleted { .. } => deleted += 1, GitFileStatus::Renamed { .. } => renamed += 1, GitFileStatus::Untracked { .. } => untracked += 1, } } let mut status_parts = Vec::new(); if modified > 0 { status_parts.push(format!("{} modified", modified)); } if added > 0 { status_parts.push(format!("{} added", added)); } if deleted > 0 { status_parts.push(format!("{} deleted", deleted)); } if renamed > 0 { status_parts.push(format!("{} renamed", renamed)); } if untracked > 0 { status_parts.push(format!("{} untracked", untracked)); } lines.push(format!("Status: {}", status_parts.join(", "))); } if let Some(url) = &state.remote_url { lines.push(format!("Remote: {}", url)); } else { lines.push("Remote: (none)".to_string()); } lines.join("\n") } #[cfg(test)] mod tests { use super::*; #[test] fn test_is_safe_git_command() { // Safe commands assert!(is_safe_git_command("git status")); assert!(is_safe_git_command("git log --oneline")); assert!(is_safe_git_command("git diff HEAD")); assert!(is_safe_git_command("git branch -v")); assert!(is_safe_git_command("git remote -v")); assert!(is_safe_git_command("git config --get user.name")); // Unsafe commands assert!(!is_safe_git_command("git commit -m test")); assert!(!is_safe_git_command("git push origin main")); assert!(!is_safe_git_command("git branch -D feature")); assert!(!is_safe_git_command("git remote add origin url")); } #[test] fn test_is_destructive_git_command() { // Destructive commands let (is_dest, msg) = is_destructive_git_command("git push --force origin main"); assert!(is_dest); assert!(msg.contains("Force push")); let (is_dest, msg) = is_destructive_git_command("git reset --hard HEAD~1"); assert!(is_dest); assert!(msg.contains("Hard reset")); let (is_dest, msg) = is_destructive_git_command("git clean -fd"); assert!(is_dest); assert!(msg.contains("clean")); let (is_dest, msg) = is_destructive_git_command("git rebase main"); assert!(is_dest); assert!(msg.contains("Rebase")); let (is_dest, msg) = is_destructive_git_command("git commit --amend"); assert!(is_dest); assert!(msg.contains("Amending")); // Non-destructive commands let (is_dest, _) = is_destructive_git_command("git status"); assert!(!is_dest); let (is_dest, _) = is_destructive_git_command("git log"); assert!(!is_dest); let (is_dest, _) = is_destructive_git_command("git diff"); assert!(!is_dest); } #[test] fn test_git_state_not_a_repo() { let state = GitState::not_a_repo(); assert!(!state.is_git_repo); assert!(state.current_branch.is_none()); assert!(state.main_branch.is_none()); assert!(state.status.is_empty()); assert!(!state.has_uncommitted_changes); assert!(state.remote_url.is_none()); } #[test] fn test_git_file_status_path() { let status = GitFileStatus::Modified { path: "test.rs".to_string(), }; assert_eq!(status.path(), "test.rs"); let status = GitFileStatus::Renamed { from: "old.rs".to_string(), to: "new.rs".to_string(), }; assert_eq!(status.path(), "new.rs"); } #[test] fn test_format_git_status_not_repo() { let state = GitState::not_a_repo(); let formatted = format_git_status(&state); assert_eq!(formatted, "Not a git repository"); } #[test] fn test_format_git_status_clean() { let state = GitState { is_git_repo: true, current_branch: Some("main".to_string()), main_branch: Some("main".to_string()), status: Vec::new(), has_uncommitted_changes: false, remote_url: Some("https://github.com/user/repo.git".to_string()), }; let formatted = format_git_status(&state); assert!(formatted.contains("Git Repository: yes")); assert!(formatted.contains("Current branch: main")); assert!(formatted.contains("clean working tree")); } #[test] fn test_format_git_status_with_changes() { let state = GitState { is_git_repo: true, current_branch: Some("feature".to_string()), main_branch: Some("main".to_string()), status: vec![ GitFileStatus::Modified { path: "file1.rs".to_string(), }, GitFileStatus::Modified { path: "file2.rs".to_string(), }, GitFileStatus::Untracked { path: "new.rs".to_string(), }, ], has_uncommitted_changes: true, remote_url: None, }; let formatted = format_git_status(&state); assert!(formatted.contains("2 modified")); assert!(formatted.contains("1 untracked")); } }