eazygit 0.5.1

A fast TUI for Git with staging, conflicts, rebase, and palette-first UX
Documentation
//! Git CLI wrapper modules organized by operation type.
//!
//! Split from cli.rs (923 lines) for maintainability:
//! - staging: add, restore, diff
//! - commit: commit, amend, last commit info
//! - branch: branch operations
//! - remote: push, fetch, remote management
//! - history: cherry-pick, revert, reset, log
//! - rebase: rebase operations
//! - misc: other operations

mod staging;
mod commit;
mod branch;
mod remote;
mod history;
mod rebase;
mod misc;

use anyhow::Result;
use std::process::Command;

/// Wrapper for invoking the git CLI (network/auth paths).
pub struct GitCli;

impl GitCli {
    pub fn new() -> Self {
        Self
    }
}

// Helper function for running git commands
pub(crate) fn run_git_cmd(repo_path: &str, args: &[&str]) -> Result<String> {
    let mut cmd = Command::new("git");
    cmd.arg("-C").arg(repo_path);
    for arg in args {
        cmd.arg(arg);
    }
    let output = cmd.output()?;
    if !output.status.success() {
        anyhow::bail!(
            "git {} failed: {}",
            args.first().unwrap_or(&""),
            String::from_utf8_lossy(&output.stderr)
        );
    }
    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}

pub(crate) fn run_git_cmd_ok(repo_path: &str, args: &[&str]) -> Result<()> {
    run_git_cmd(repo_path, args)?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;
    use std::fs;
    use std::process::Command;

    fn setup_test_repo() -> std::path::PathBuf {
        let temp_dir = tempdir().expect("Failed to create temp directory");
        let repo_path = temp_dir.path().to_path_buf();
        
        Command::new("git")
            .arg("init")
            .arg(&repo_path)
            .output()
            .expect("Failed to init repo");
        
        Command::new("git")
            .args(["-C", repo_path.to_str().unwrap(), "config", "user.name", "Test"])
            .output()
            .expect("Failed to set user.name");
        
        Command::new("git")
            .args(["-C", repo_path.to_str().unwrap(), "config", "user.email", "test@test.com"])
            .output()
            .expect("Failed to set user.email");
        
        std::mem::forget(temp_dir);
        repo_path
    }

    #[test]
    fn test_status_porcelain_parsing() {
        let repo_path = setup_test_repo();
        let git_cli = GitCli::new();
        
        // Create a test file
        let test_file = repo_path.join("test.txt");
        fs::write(&test_file, "content").expect("Failed to write file");
        
        let status = git_cli.status_porcelain(repo_path.to_str().unwrap())
            .expect("Failed to get status");
        
        // Should contain untracked file
        assert!(status.contains("test.txt") || status.contains("??"));
        
        let _ = fs::remove_dir_all(&repo_path);
    }

    #[test]
    fn test_staging_operations() {
        let repo_path = setup_test_repo();
        let git_cli = GitCli::new();
        
        // Create initial commit
        let test_file1 = repo_path.join("file1.txt");
        fs::write(&test_file1, "content1").expect("Failed to write file");
        Command::new("git")
            .args(["-C", repo_path.to_str().unwrap(), "add", "file1.txt"])
            .output()
            .expect("Failed to stage");
        Command::new("git")
            .args(["-C", repo_path.to_str().unwrap(), "commit", "-m", "Initial"])
            .output()
            .expect("Failed to commit");
        
        // Create and stage a new file
        let test_file2 = repo_path.join("file2.txt");
        fs::write(&test_file2, "content2").expect("Failed to write file");
        
        git_cli.stage_file(repo_path.to_str().unwrap(), "file2.txt")
            .expect("Failed to stage file");
        
        // Verify staged
        let status = git_cli.status_porcelain(repo_path.to_str().unwrap())
            .expect("Failed to get status");
        assert!(status.contains("A ") || status.contains("file2.txt"));
        
        // Unstage
        git_cli.unstage_file(repo_path.to_str().unwrap(), "file2.txt")
            .expect("Failed to unstage file");
        
        let _ = fs::remove_dir_all(&repo_path);
    }

    #[test]
    fn test_error_handling() {
        let repo_path = setup_test_repo();
        let git_cli = GitCli::new();
        
        // Try to stage non-existent file - should handle gracefully
        let result = git_cli.stage_file(repo_path.to_str().unwrap(), "nonexistent.txt");
        // This might succeed (git add creates empty file) or fail, both are acceptable
        assert!(result.is_ok() || result.is_err());
        
        let _ = fs::remove_dir_all(&repo_path);
    }

    #[test]
    fn test_branch_operations() {
        let repo_path = setup_test_repo();
        let git_cli = GitCli::new();
        
        // Create initial commit
        let test_file = repo_path.join("file.txt");
        fs::write(&test_file, "content").expect("Failed to write file");
        Command::new("git")
            .args(["-C", repo_path.to_str().unwrap(), "add", "file.txt"])
            .output()
            .expect("Failed to stage");
        Command::new("git")
            .args(["-C", repo_path.to_str().unwrap(), "commit", "-m", "Initial"])
            .output()
            .expect("Failed to commit");
        
        // Create branch using git command directly (GitCli doesn't have create_branch)
        Command::new("git")
            .args(["-C", repo_path.to_str().unwrap(), "branch", "test-branch"])
            .output()
            .expect("Failed to create branch");
        
        // Checkout branch using git command directly (GitCli doesn't have checkout_branch)
        Command::new("git")
            .args(["-C", repo_path.to_str().unwrap(), "checkout", "test-branch"])
            .output()
            .expect("Failed to checkout");
        
        // Verify current branch
        let current = git_cli.current_branch(repo_path.to_str().unwrap())
            .expect("Failed to get current branch");
        assert_eq!(current, "test-branch");
        
        let _ = fs::remove_dir_all(&repo_path);
    }

    #[test]
    fn test_rebase_operations() {
        let repo_path = setup_test_repo();
        let git_cli = GitCli::new();
        
        // Create multiple commits
        for i in 0..3 {
            let file = repo_path.join(format!("file{}.txt", i));
            fs::write(&file, format!("content {}", i)).expect("Failed to write");
            Command::new("git")
                .args(["-C", repo_path.to_str().unwrap(), "add", &format!("file{}.txt", i)])
                .output()
                .expect("Failed to stage");
            Command::new("git")
                .args(["-C", repo_path.to_str().unwrap(), "commit", "-m", &format!("Commit {}", i)])
                .output()
                .expect("Failed to commit");
        }
        
        // Get base commit
        let output = Command::new("git")
            .args(["-C", repo_path.to_str().unwrap(), "rev-parse", "HEAD~2"])
            .output()
            .expect("Failed to get commit");
        let base = String::from_utf8_lossy(&output.stdout).trim().to_string();
        
        // Start rebase
        let todo_path = git_cli.start_rebase_interactive(repo_path.to_str().unwrap(), &base)
            .expect("Failed to start rebase");
        assert!(std::path::Path::new(&todo_path).exists());
        
        // Read todo
        let (lines, _) = git_cli.read_rebase_todo(repo_path.to_str().unwrap())
            .expect("Failed to read todo");
        assert!(!lines.is_empty());
        
        // Abort rebase
        git_cli.rebase_abort(repo_path.to_str().unwrap())
            .expect("Failed to abort");
        
        let _ = fs::remove_dir_all(&repo_path);
    }
}