gmap 0.3.3

Git repository analysis tool for churn and heatmap visualization
Documentation
use crate::cache::Cache;
use crate::cli::CommonArgs;
use crate::error::Result;
use crate::git::GitRepo;
use crate::model::{ChurnEntry, ChurnOutput, CommitStats};
use anyhow::Context;
use chrono::Utc;
use console::style;
use std::collections::{HashMap, HashSet};

pub fn exec(common: CommonArgs, depth: Option<u32>, json: bool, ndjson: bool, path: Option<String>) -> anyhow::Result<()> {
    let repo = GitRepo::open(common.repo.as_ref()).context("Failed to open git repository")?;
    let mut cache = Cache::new(common.cache.as_deref(), repo.path()).context("Failed to initialize cache")?;

    let range = repo
        .resolve_range(common.since.as_deref(), common.until.as_deref())
        .context("Failed to resolve date range")?;

    let mut cached = cache
        .get_commit_stats(&range)
        .context("Failed to get cached commit stats")?;

    let repo_stats = repo
        .collect_commits(&range, common.include_merges, common.binary)
        .context("Failed to collect commits from repository")?;

    let existing_ids: HashSet<&str> = cached.iter().map(|c| c.commit_id.as_str()).collect();
    let missing: Vec<CommitStats> = repo_stats
        .into_iter()
        .filter(|s| !existing_ids.contains(s.commit_id.as_str()))
        .collect();

    if !missing.is_empty() {
        let mut infos: HashMap<_, _> = HashMap::new();
        for s in &missing {
            if let Ok(Some(info)) = cache.get_commit_info(&s.commit_id) {
                infos.insert(s.commit_id.clone(), info);
            } else if let Ok(info) = repo.get_commit_info(&s.commit_id) {
                infos.insert(s.commit_id.clone(), info);
            }
        }

        cache
            .store_commit_stats(&missing, &infos)
            .context("Failed to store commit stats in cache")?;

        cached.extend(missing);
    }

    let churn = compute_churn(&cached, &cache, depth, path.as_deref())
        .context("Failed to compute churn statistics")?;

    if json {
        output_json(&churn, &repo, &common, depth)?;
    } else if ndjson {
        output_ndjson(&churn)?;
    } else {
        output_table(&churn)?;
    }

    Ok(())
}

fn compute_churn(
    stats: &[CommitStats],
    cache: &Cache,
    depth: Option<u32>,
    path_prefix: Option<&str>,
) -> Result<Vec<ChurnEntry>> {
    let mut map: HashMap<String, ChurnEntry> = HashMap::new();
    for cs in stats {
        let info = cache
            .get_commit_info(&cs.commit_id)?
            .ok_or_else(|| crate::error::GmapError::Cache("Commit info not found".to_string()))?;

        for f in &cs.files {
            if let Some(prefix) = path_prefix {
                if !f.path.starts_with(prefix) {
                    continue;
                }
            }
            let agg = if let Some(d) = depth {
                aggregate_path(&f.path, d)
            } else {
                f.path.clone()
            };
            let entry = map.entry(agg.clone()).or_insert_with(|| ChurnEntry::new(agg));
            entry.add_stats(f, &info.author_name);
        }
    }
    let mut entries: Vec<_> = map.into_values().collect();
    entries.sort_by(|a, b| b.total_lines.cmp(&a.total_lines));
    Ok(entries)
}

fn aggregate_path(path: &str, depth: u32) -> String {
    let parts: Vec<&str> = path.split('/').collect();
    if depth == 0 || parts.len() <= depth as usize {
        path.to_string()
    } else {
        parts[..depth as usize].join("/")
    }
}

fn output_json(churn_data: &[ChurnEntry], repo: &GitRepo, common: &CommonArgs, depth: Option<u32>) -> anyhow::Result<()> {
    let output = ChurnOutput {
        version: crate::model::SCHEMA_VERSION,
        generated_at: Utc::now(),
        repository_path: repo.path().to_string_lossy().to_string(),
        since: common.since.clone(),
        until: common.until.clone(),
        depth,
        entries: churn_data.to_vec(),
    };
    println!("{}", serde_json::to_string_pretty(&output)?);
    Ok(())
}

fn output_ndjson(churn_data: &[ChurnEntry]) -> anyhow::Result<()> {
    for e in churn_data {
        println!("{}", serde_json::to_string(e)?);
    }
    Ok(())
}

fn output_table(churn_data: &[ChurnEntry]) -> anyhow::Result<()> {
    println!(
        "{:<50} {:>8} {:>8} {:>8} {:>6} {:>8}",
        style("Path").bold(),
        style("Added").bold(),
        style("Deleted").bold(),
        style("Total").bold(),
        style("Commits").bold(),
        style("Authors").bold()
    );
    println!("{}", "".repeat(98));
    for e in churn_data.iter().take(50) {
        println!(
            "{:<50} {:>8} {:>8} {:>8} {:>6} {:>8}",
            e.path,
            e.added_lines,
            e.deleted_lines,
            e.total_lines,
            e.commit_count,
            e.authors.len()
        );
    }
    if churn_data.len() > 50 {
        println!("\n... and {} more entries", churn_data.len() - 50);
    }
    Ok(())
}