gitgrip 0.10.0

Multi-repo workflow tool - manage multiple git repositories as one
Documentation
//! Git status operations

use git2::Repository;
use std::path::PathBuf;
use std::process::Command;

use super::cache::STATUS_CACHE;
use super::{get_current_branch, open_repo, path_exists, GitError};
use crate::core::repo::RepoInfo;
use crate::util::log_cmd;

/// Repository status information
#[derive(Debug, Clone)]
pub struct RepoStatusInfo {
    /// Current branch name
    pub current_branch: String,
    /// Is the working directory clean
    pub is_clean: bool,
    /// Staged files
    pub staged: Vec<String>,
    /// Modified files (not staged)
    pub modified: Vec<String>,
    /// Untracked files
    pub untracked: Vec<String>,
    /// Commits ahead of remote
    pub ahead: usize,
    /// Commits behind remote
    pub behind: usize,
}

/// Repository status with name
#[derive(Debug, Clone)]
pub struct RepoStatus {
    /// Repository name
    pub name: String,
    /// Current branch
    pub branch: String,
    /// Is clean
    pub clean: bool,
    /// Staged file count
    pub staged: usize,
    /// Modified file count
    pub modified: usize,
    /// Untracked file count
    pub untracked: usize,
    /// Commits ahead of upstream
    pub ahead: usize,
    /// Commits behind upstream
    pub behind: usize,
    /// Commits ahead of default branch (main)
    pub ahead_main: usize,
    /// Commits behind default branch (main)
    pub behind_main: usize,
    /// Whether repo exists
    pub exists: bool,
}

/// Get detailed status for a repository using git2
pub fn get_status_info(repo: &Repository) -> Result<RepoStatusInfo, GitError> {
    let current_branch = get_current_branch(repo)?;

    // Use git porcelain status for reliable parsing
    let repo_path = super::get_workdir(repo);

    let mut cmd = Command::new("git");
    cmd.args(["status", "--porcelain=v1"])
        .current_dir(repo_path);
    log_cmd(&cmd);
    let output = cmd
        .output()
        .map_err(|e| GitError::OperationFailed(e.to_string()))?;

    let stdout = String::from_utf8_lossy(&output.stdout);

    let mut staged = Vec::new();
    let mut modified = Vec::new();
    let mut untracked = Vec::new();

    for line in stdout.lines() {
        if line.len() < 3 {
            continue;
        }
        let index_status = line.chars().next().unwrap_or(' ');
        let worktree_status = line.chars().nth(1).unwrap_or(' ');
        let path = line[3..].to_string();

        // Staged changes (index)
        if matches!(index_status, 'A' | 'M' | 'D' | 'R' | 'C') {
            staged.push(path.clone());
        }

        // Worktree changes
        if matches!(worktree_status, 'M' | 'D') {
            modified.push(path.clone());
        }

        // Untracked
        if index_status == '?' && worktree_status == '?' {
            untracked.push(path);
        }
    }

    let is_clean = staged.is_empty() && modified.is_empty() && untracked.is_empty();

    // Get ahead/behind counts
    let (ahead, behind) = get_ahead_behind_git(repo_path).unwrap_or((0, 0));

    Ok(RepoStatusInfo {
        current_branch,
        is_clean,
        staged,
        modified,
        untracked,
        ahead,
        behind,
    })
}

/// Get cached status or compute it
pub fn get_cached_status(repo_path: &PathBuf) -> Result<RepoStatusInfo, GitError> {
    // Check cache first
    if let Some(status) = STATUS_CACHE.get(repo_path) {
        return Ok(status);
    }

    // Compute and cache
    let repo = open_repo(repo_path)?;
    let status = get_status_info(&repo)?;
    STATUS_CACHE.set(repo_path.clone(), status.clone());
    Ok(status)
}

/// Get ahead/behind counts using git rev-list
fn get_ahead_behind_git(repo_path: &std::path::Path) -> Option<(usize, usize)> {
    let mut cmd = Command::new("git");
    cmd.args(["rev-list", "--left-right", "--count", "@{upstream}...HEAD"])
        .current_dir(repo_path);
    log_cmd(&cmd);
    let output = cmd.output().ok()?;

    if !output.status.success() {
        return Some((0, 0));
    }

    parse_ahead_behind(&output.stdout)
}

/// Get commits ahead/behind a specific branch (e.g., main)
fn get_ahead_behind_branch(
    repo_path: &std::path::Path,
    base_branch: &str,
) -> Option<(usize, usize)> {
    // Try remote first: origin/{base_branch}
    let remote_ref = format!("origin/{}", base_branch);

    let mut cmd = Command::new("git");
    cmd.args([
        "rev-list",
        "--left-right",
        "--count",
        &format!("{}...HEAD", remote_ref),
    ])
    .current_dir(repo_path);
    log_cmd(&cmd);
    let output = cmd.output().ok()?;

    if !output.status.success() {
        // Fallback to local branch
        let mut cmd = Command::new("git");
        cmd.args([
            "rev-list",
            "--left-right",
            "--count",
            &format!("{}...HEAD", base_branch),
        ])
        .current_dir(repo_path);
        log_cmd(&cmd);
        let output = cmd.output().ok()?;

        if !output.status.success() {
            return Some((0, 0));
        }
        return parse_ahead_behind(&output.stdout);
    }

    parse_ahead_behind(&output.stdout)
}

/// Parse ahead/behind counts from git rev-list output
fn parse_ahead_behind(stdout: &[u8]) -> Option<(usize, usize)> {
    let stdout = String::from_utf8_lossy(stdout);
    let parts: Vec<&str> = stdout.trim().split('\t').collect();

    if parts.len() == 2 {
        let behind = parts[0].parse().unwrap_or(0);
        let ahead = parts[1].parse().unwrap_or(0);
        Some((ahead, behind))
    } else {
        Some((0, 0))
    }
}

/// Get repository status
pub fn get_repo_status(repo_info: &RepoInfo) -> RepoStatus {
    if !path_exists(&repo_info.absolute_path) {
        return RepoStatus {
            name: repo_info.name.clone(),
            branch: String::new(),
            clean: true,
            staged: 0,
            modified: 0,
            untracked: 0,
            ahead: 0,
            behind: 0,
            ahead_main: 0,
            behind_main: 0,
            exists: false,
        };
    }

    match get_cached_status(&repo_info.absolute_path) {
        Ok(status) => {
            // Get ahead/behind counts vs default branch
            let (ahead_main, behind_main) =
                get_ahead_behind_branch(&repo_info.absolute_path, &repo_info.default_branch)
                    .unwrap_or((0, 0));

            RepoStatus {
                name: repo_info.name.clone(),
                branch: status.current_branch,
                clean: status.is_clean,
                staged: status.staged.len(),
                modified: status.modified.len(),
                untracked: status.untracked.len(),
                ahead: status.ahead,
                behind: status.behind,
                ahead_main,
                behind_main,
                exists: true,
            }
        }
        Err(_) => RepoStatus {
            name: repo_info.name.clone(),
            branch: "error".to_string(),
            clean: true,
            staged: 0,
            modified: 0,
            untracked: 0,
            ahead: 0,
            behind: 0,
            ahead_main: 0,
            behind_main: 0,
            exists: true,
        },
    }
}

/// Get status for all repositories
pub fn get_all_repo_status(repos: &[RepoInfo]) -> Vec<RepoStatus> {
    repos.iter().map(get_repo_status).collect()
}

/// Get list of changed files (staged, modified, and untracked)
pub fn get_changed_files(repo: &Repository) -> Result<Vec<String>, GitError> {
    let status = get_status_info(repo)?;
    let mut files = status.staged;
    files.extend(status.modified);
    files.extend(status.untracked);
    Ok(files)
}

/// Check if there are uncommitted changes
pub fn has_uncommitted_changes(repo: &Repository) -> Result<bool, GitError> {
    let status = get_status_info(repo)?;
    Ok(!status.is_clean)
}

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

    fn setup_test_repo() -> (TempDir, Repository) {
        let temp = TempDir::new().unwrap();

        Command::new("git")
            .args(["init"])
            .current_dir(temp.path())
            .output()
            .unwrap();

        Command::new("git")
            .args(["config", "user.name", "Test User"])
            .current_dir(temp.path())
            .output()
            .unwrap();

        Command::new("git")
            .args(["config", "user.email", "test@example.com"])
            .current_dir(temp.path())
            .output()
            .unwrap();

        let repo = open_repo(temp.path()).unwrap();
        (temp, repo)
    }

    #[test]
    fn test_clean_repo() {
        let (temp, repo) = setup_test_repo();

        // Create initial commit
        fs::write(temp.path().join("README.md"), "# Test").unwrap();
        Command::new("git")
            .args(["add", "README.md"])
            .current_dir(temp.path())
            .output()
            .unwrap();
        Command::new("git")
            .args(["commit", "-m", "Initial commit"])
            .current_dir(temp.path())
            .output()
            .unwrap();

        let status = get_status_info(&repo).unwrap();
        assert!(status.is_clean);
        assert!(status.staged.is_empty());
        assert!(status.modified.is_empty());
        assert!(status.untracked.is_empty());
    }

    #[test]
    fn test_untracked_file() {
        let (temp, repo) = setup_test_repo();

        // Create initial commit first
        fs::write(temp.path().join("README.md"), "# Test").unwrap();
        Command::new("git")
            .args(["add", "README.md"])
            .current_dir(temp.path())
            .output()
            .unwrap();
        Command::new("git")
            .args(["commit", "-m", "Initial commit"])
            .current_dir(temp.path())
            .output()
            .unwrap();

        // Create an untracked file
        fs::write(temp.path().join("new_file.txt"), "content").unwrap();

        let status = get_status_info(&repo).unwrap();
        assert!(!status.is_clean);
        assert!(status.staged.is_empty());
        assert!(status.modified.is_empty());
        assert_eq!(status.untracked.len(), 1);
        assert!(status.untracked.contains(&"new_file.txt".to_string()));
    }

    #[test]
    fn test_staged_file() {
        let (temp, repo) = setup_test_repo();

        // Create initial commit first
        fs::write(temp.path().join("README.md"), "# Test").unwrap();
        Command::new("git")
            .args(["add", "README.md"])
            .current_dir(temp.path())
            .output()
            .unwrap();
        Command::new("git")
            .args(["commit", "-m", "Initial commit"])
            .current_dir(temp.path())
            .output()
            .unwrap();

        // Create and stage a file
        fs::write(temp.path().join("staged.txt"), "content").unwrap();
        Command::new("git")
            .args(["add", "staged.txt"])
            .current_dir(temp.path())
            .output()
            .unwrap();

        let status = get_status_info(&repo).unwrap();
        assert!(!status.is_clean);
        assert_eq!(status.staged.len(), 1);
        assert!(status.staged.contains(&"staged.txt".to_string()));
    }
}