gitgrip 0.10.0

Multi-repo workflow tool - manage multiple git repositories as one
Documentation
//! Status command implementation

use crate::cli::output::{Output, Table};
use crate::core::manifest::Manifest;
use crate::core::repo::{filter_repos, RepoInfo};
use crate::git::path_exists;
use crate::git::status::{get_repo_status, RepoStatus};
use std::path::PathBuf;

/// JSON-serializable repo status for --json output
#[derive(serde::Serialize)]
struct JsonRepoStatus {
    name: String,
    branch: String,
    clean: bool,
    staged: usize,
    modified: usize,
    untracked: usize,
    ahead: usize,
    behind: usize,
    reference: bool,
    groups: Vec<String>,
}

/// Run the status command
pub fn run_status(
    workspace_root: &PathBuf,
    manifest: &Manifest,
    verbose: bool,
    quiet: bool,
    group_filter: Option<&[String]>,
    json: bool,
) -> anyhow::Result<()> {
    if json {
        return run_status_json(workspace_root, manifest, group_filter);
    }

    Output::header("Repository Status");
    println!();

    // Get all repo info (include reference repos for display)
    let repos: Vec<RepoInfo> = filter_repos(manifest, workspace_root, None, group_filter, true);

    // Get status for all repos
    let statuses: Vec<(RepoStatus, &RepoInfo)> = repos
        .iter()
        .map(|repo| (get_repo_status(repo), repo))
        .collect();

    // Count stats
    let total = statuses.len();
    let cloned = statuses.iter().filter(|(s, _)| s.exists).count();
    let with_changes = statuses.iter().filter(|(s, _)| !s.clean).count();
    let ahead_count = statuses.iter().filter(|(s, _)| s.ahead_main > 0).count();

    // In quiet mode, only show repos with changes or not on default branch
    let filtered_statuses: Vec<&(RepoStatus, &RepoInfo)> = if quiet {
        statuses
            .iter()
            .filter(|(s, repo)| !s.clean || !s.exists || s.branch != repo.default_branch)
            .collect()
    } else {
        statuses.iter().collect()
    };

    // Display table
    let mut table = Table::new(vec!["Repo", "Branch", "Status", "vs main"]);

    for (status, repo) in &filtered_statuses {
        let status_str = format_status(status, verbose);
        let main_str = format_main_comparison(status, &repo.default_branch);
        // Add [ref] suffix for reference repos
        let repo_display = if repo.reference {
            format!("{} [ref]", Output::repo_name(&status.name))
        } else {
            Output::repo_name(&status.name)
        };
        table.add_row(vec![
            &repo_display,
            &Output::branch_name(&status.branch),
            &status_str,
            &main_str,
        ]);
    }

    if !filtered_statuses.is_empty() {
        table.print();
    }

    // Show manifest worktree status if it exists
    let manifests_dir = workspace_root.join(".gitgrip").join("manifests");
    let manifests_git_dir = manifests_dir.join(".git");
    if manifests_git_dir.exists() && path_exists(&manifests_dir) {
        println!();
        // Create a minimal RepoInfo for the manifest
        let manifest_repo_info = RepoInfo {
            name: "manifest".to_string(),
            url: String::new(),
            path: ".gitgrip/manifests".to_string(),
            absolute_path: manifests_dir.clone(),
            default_branch: "main".to_string(),
            owner: String::new(),
            repo: "manifests".to_string(),
            platform_type: crate::core::manifest::PlatformType::GitHub,
            platform_base_url: None,
            project: None,
            reference: false,
            groups: Vec::new(),
        };

        let status = get_repo_status(&manifest_repo_info);
        let status_str = format_status(&status, verbose);
        let main_str = format_main_comparison(&status, &manifest_repo_info.default_branch);
        let mut manifest_table = Table::new(vec!["Repo", "Branch", "Status", "vs main"]);
        manifest_table.add_row(vec![
            &Output::repo_name("manifest"),
            &Output::branch_name(&status.branch),
            &status_str,
            &main_str,
        ]);
        manifest_table.print();
    }

    // Summary
    println!();
    if quiet {
        // Machine-readable summary line
        println!(
            "SUMMARY: repos={} cloned={} changes={} ahead={}",
            total, cloned, with_changes, ahead_count
        );
    } else {
        let ahead_suffix = if ahead_count > 0 {
            format!(" | {} ahead of main", ahead_count)
        } else {
            String::new()
        };
        println!(
            "  {}/{} cloned | {} with changes{}",
            cloned, total, with_changes, ahead_suffix
        );
    }

    Ok(())
}

/// Run status in JSON mode
fn run_status_json(
    workspace_root: &PathBuf,
    manifest: &Manifest,
    group_filter: Option<&[String]>,
) -> anyhow::Result<()> {
    let repos: Vec<RepoInfo> = filter_repos(manifest, workspace_root, None, group_filter, true);

    let json_statuses: Vec<JsonRepoStatus> = repos
        .iter()
        .map(|repo| {
            let status = get_repo_status(repo);
            JsonRepoStatus {
                name: status.name,
                branch: status.branch,
                clean: status.clean,
                staged: status.staged,
                modified: status.modified,
                untracked: status.untracked,
                ahead: status.ahead_main,
                behind: status.behind_main,
                reference: repo.reference,
                groups: repo.groups.clone(),
            }
        })
        .collect();

    println!("{}", serde_json::to_string_pretty(&json_statuses)?);
    Ok(())
}

/// Format the vs main comparison column
fn format_main_comparison(status: &RepoStatus, default_branch: &str) -> String {
    // On default branch - no comparison needed
    if status.branch == default_branch {
        return "-".to_string();
    }

    if status.ahead_main == 0 && status.behind_main == 0 {
        return "\u{2713}".to_string(); // checkmark
    }

    let mut parts = Vec::new();
    if status.ahead_main > 0 {
        parts.push(format!("\u{2191}{}", status.ahead_main)); // up arrow
    }
    if status.behind_main > 0 {
        parts.push(format!("\u{2193}{}", status.behind_main)); // down arrow
    }
    parts.join(" ")
}

/// Format status for display
fn format_status(status: &RepoStatus, verbose: bool) -> String {
    if !status.exists {
        return "not cloned".to_string();
    }

    if status.clean {
        return "".to_string();
    }

    let mut parts = Vec::new();

    if status.staged > 0 {
        parts.push(format!("+{}", status.staged));
    }
    if status.modified > 0 {
        parts.push(format!("~{}", status.modified));
    }
    if status.untracked > 0 {
        parts.push(format!("?{}", status.untracked));
    }

    if verbose {
        if status.ahead > 0 {
            parts.push(format!("{}", status.ahead));
        }
        if status.behind > 0 {
            parts.push(format!("{}", status.behind));
        }
    }

    parts.join(" ")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_format_status_clean() {
        let status = RepoStatus {
            name: "test".to_string(),
            branch: "main".to_string(),
            clean: true,
            staged: 0,
            modified: 0,
            untracked: 0,
            ahead: 0,
            behind: 0,
            ahead_main: 0,
            behind_main: 0,
            exists: true,
        };
        assert_eq!(format_status(&status, false), "");
    }

    #[test]
    fn test_format_status_changes() {
        let status = RepoStatus {
            name: "test".to_string(),
            branch: "main".to_string(),
            clean: false,
            staged: 2,
            modified: 3,
            untracked: 1,
            ahead: 0,
            behind: 0,
            ahead_main: 0,
            behind_main: 0,
            exists: true,
        };
        assert_eq!(format_status(&status, false), "+2 ~3 ?1");
    }

    #[test]
    fn test_format_status_ahead_behind() {
        let status = RepoStatus {
            name: "test".to_string(),
            branch: "feat".to_string(),
            clean: false,
            staged: 1,
            modified: 0,
            untracked: 0,
            ahead: 3,
            behind: 1,
            ahead_main: 0,
            behind_main: 0,
            exists: true,
        };
        assert_eq!(format_status(&status, true), "+1 ↑3 ↓1");
    }

    #[test]
    fn test_format_main_comparison_on_main() {
        let status = RepoStatus {
            name: "test".to_string(),
            branch: "main".to_string(),
            clean: true,
            staged: 0,
            modified: 0,
            untracked: 0,
            ahead: 0,
            behind: 0,
            ahead_main: 0,
            behind_main: 0,
            exists: true,
        };
        assert_eq!(format_main_comparison(&status, "main"), "-");
    }

    #[test]
    fn test_format_main_comparison_ahead() {
        let status = RepoStatus {
            name: "test".to_string(),
            branch: "feat/test".to_string(),
            clean: true,
            staged: 0,
            modified: 0,
            untracked: 0,
            ahead: 0,
            behind: 0,
            ahead_main: 5,
            behind_main: 0,
            exists: true,
        };
        assert_eq!(format_main_comparison(&status, "main"), "↑5");
    }

    #[test]
    fn test_format_main_comparison_behind() {
        let status = RepoStatus {
            name: "test".to_string(),
            branch: "feat/test".to_string(),
            clean: true,
            staged: 0,
            modified: 0,
            untracked: 0,
            ahead: 0,
            behind: 0,
            ahead_main: 0,
            behind_main: 3,
            exists: true,
        };
        assert_eq!(format_main_comparison(&status, "main"), "↓3");
    }

    #[test]
    fn test_format_main_comparison_both() {
        let status = RepoStatus {
            name: "test".to_string(),
            branch: "feat/test".to_string(),
            clean: true,
            staged: 0,
            modified: 0,
            untracked: 0,
            ahead: 0,
            behind: 0,
            ahead_main: 2,
            behind_main: 5,
            exists: true,
        };
        assert_eq!(format_main_comparison(&status, "main"), "↑2 ↓5");
    }

    #[test]
    fn test_format_main_comparison_in_sync() {
        let status = RepoStatus {
            name: "test".to_string(),
            branch: "feat/test".to_string(),
            clean: true,
            staged: 0,
            modified: 0,
            untracked: 0,
            ahead: 0,
            behind: 0,
            ahead_main: 0,
            behind_main: 0,
            exists: true,
        };
        assert_eq!(format_main_comparison(&status, "main"), "");
    }
}