gitstack 5.3.0

Git history viewer with insights - Author stats, file heatmap, code ownership
Documentation
use std::sync::mpsc;
use std::thread;

use anyhow::Result;

use gitstack::{
    build_handoff_context, build_next_actions, build_review_pack, calculate_file_heatmap,
    calculate_stats, get_commit_files, load_cached_review_pack, record_quick_action_usage,
    save_cached_review_pack, verify_patch_risk, App, GitEvent, Language, QuickAction, ReviewPack,
    MIN_COMMITS_FOR_COUPLING, MIN_COUPLING_RATIO,
};

use crate::key_handlers::set_error_status;
use crate::{AnalysisResult, BackgroundAnalysisState};

/// Load or build a review pack for TUI display
pub fn load_or_build_review_pack_for_tui(app: &App) -> Result<ReviewPack> {
    let events: Vec<_> = app.events().collect();
    let selected = app.selected_event().map(|e| e.short_hash.as_str());
    if let Some(pack) = load_cached_review_pack(selected) {
        return Ok(pack);
    }
    let pack = build_review_pack(None, &events, selected)?;
    let _ = save_cached_review_pack(selected, &pack);
    Ok(pack)
}

/// Convert errors into user-friendly messages
pub fn friendly_error_message(e: &anyhow::Error, lang: Language) -> String {
    // Check for io::Error
    if let Some(io_err) = e.downcast_ref::<std::io::Error>() {
        return match io_err.kind() {
            std::io::ErrorKind::NotFound => lang.error_not_found().to_string(),
            std::io::ErrorKind::PermissionDenied => lang.error_permission_denied().to_string(),
            std::io::ErrorKind::ConnectionRefused => lang.error_connection_refused().to_string(),
            std::io::ErrorKind::TimedOut => lang.error_connection_timeout().to_string(),
            _ => e.to_string(),
        };
    }

    // Check for git2::Error
    if let Some(git_err) = e.downcast_ref::<git2::Error>() {
        let msg = git_err.message();
        let msg_lower = msg.to_lowercase();
        if msg_lower.contains("authentication")
            || msg_lower.contains("credential")
            || msg_lower.contains("auth")
        {
            return lang.error_auth_failed().to_string();
        }
        if msg_lower.contains("conflict") {
            return lang.error_merge_conflict().to_string();
        }
        if msg_lower.contains("uncommitted")
            || msg_lower.contains("dirty")
            || msg_lower.contains("overwritten by checkout")
        {
            return lang.error_dirty_worktree().to_string();
        }
        if msg_lower.contains("not found") && msg_lower.contains("branch") {
            return lang.error_branch_not_found().to_string();
        }
        if msg_lower.contains("already exists") {
            return lang.error_branch_already_exists().to_string();
        }
        if msg_lower.contains("not a git repository") {
            return lang.error_not_a_repo().to_string();
        }
        // Return git2 errors as-is
        return msg.to_string();
    }

    // String-based fallback (for errors via anyhow context chain, etc.)
    let msg = e.to_string();
    let msg_lower = msg.to_lowercase();
    if msg_lower.contains("permission denied") {
        return lang.error_permission_denied().to_string();
    }
    if msg_lower.contains("no such file") || msg_lower.contains("not found") {
        return lang.error_not_found().to_string();
    }
    if msg_lower.contains("timed out") || msg_lower.contains("timeout") {
        return lang.error_connection_timeout().to_string();
    }
    if msg_lower.contains("connection refused") {
        return lang.error_connection_refused().to_string();
    }

    msg
}

/// Execute a quick action
pub fn execute_quick_action(
    app: &mut App,
    action: QuickAction,
    bg_analysis_state: &mut BackgroundAnalysisState,
) {
    // Instant view actions (no background thread needed)
    match action {
        QuickAction::AuthorStats => {
            let events: Vec<_> = app.events().collect();
            let stats = calculate_stats(&events);
            app.start_stats_view(stats);
            return;
        }
        QuickAction::Heatmap => {
            let events: Vec<_> = app.events().collect();
            let heatmap = calculate_file_heatmap(&events, |hash| get_commit_files(hash).ok());
            app.start_heatmap_view(heatmap);
            return;
        }
        _ => {}
    }

    // Analysis actions -> launch in background
    if execute_analysis_action(app, &action, bg_analysis_state) {
        return;
    }

    // Review pack actions
    execute_review_action(app, action);
}

/// Analysis quick actions (Timeline, Ownership, ImpactScore, etc.) — launched in background
fn execute_analysis_action(
    app: &mut App,
    action: &QuickAction,
    bg_analysis_state: &mut BackgroundAnalysisState,
) -> bool {
    use gitstack::{
        calculate_activity_timeline, calculate_change_coupling, calculate_file_heatmap,
        calculate_impact_scores, calculate_ownership, calculate_quality_scores,
        get_commit_files_from_repo,
    };

    if bg_analysis_state.is_running() {
        app.set_status_message("Analysis already in progress...".to_string());
        return true;
    }

    let events: Vec<GitEvent> = app.events().cloned().collect();
    let action = *action;

    match action {
        QuickAction::Timeline
        | QuickAction::Ownership
        | QuickAction::ImpactScore
        | QuickAction::ChangeCoupling
        | QuickAction::QualityScore => {
            app.set_status_message("Calculating...".to_string());
            let (tx, rx) = mpsc::channel();
            thread::spawn(move || {
                let repo = match git2::Repository::discover(".") {
                    Ok(r) => r,
                    Err(_) => return,
                };
                let event_refs: Vec<&GitEvent> = events.iter().collect();
                // Pre-populate file cache to avoid N+1 discover_repo() calls
                let mut file_cache: std::collections::HashMap<String, Vec<String>> =
                    std::collections::HashMap::new();
                // Only pre-load for actions that need file data
                if !matches!(action, QuickAction::Timeline) {
                    for event in &event_refs {
                        if !file_cache.contains_key(&event.short_hash) {
                            if let Ok(files) = get_commit_files_from_repo(&repo, &event.short_hash)
                            {
                                file_cache.insert(event.short_hash.clone(), files);
                            }
                        }
                    }
                }
                let file_cache_ref = &file_cache;
                let get_files =
                    |hash: &str| -> Option<Vec<String>> { file_cache_ref.get(hash).cloned() };
                let result = match action {
                    QuickAction::Timeline => {
                        AnalysisResult::Timeline(Box::new(calculate_activity_timeline(&event_refs)))
                    }
                    QuickAction::Ownership => {
                        AnalysisResult::Ownership(calculate_ownership(&event_refs, get_files))
                    }
                    QuickAction::ImpactScore => {
                        let heatmap = calculate_file_heatmap(&event_refs, get_files);
                        AnalysisResult::ImpactScore(calculate_impact_scores(
                            &event_refs,
                            get_files,
                            &heatmap,
                        ))
                    }
                    QuickAction::ChangeCoupling => {
                        AnalysisResult::ChangeCoupling(calculate_change_coupling(
                            &event_refs,
                            get_files,
                            MIN_COMMITS_FOR_COUPLING,
                            MIN_COUPLING_RATIO,
                        ))
                    }
                    QuickAction::QualityScore => {
                        let coupling = calculate_change_coupling(
                            &event_refs,
                            get_files,
                            MIN_COMMITS_FOR_COUPLING,
                            MIN_COUPLING_RATIO,
                        );
                        AnalysisResult::QualityScore(calculate_quality_scores(
                            &event_refs,
                            get_files,
                            &coupling,
                        ))
                    }
                    _ => unreachable!(),
                };
                let _ = tx.send(result);
            });
            bg_analysis_state.result_rx = Some(rx);
            true
        }
        _ => false,
    }
}

/// Review pack quick actions (RiskSummary, ReviewPack, Handoff, etc.)
/// Opens an overlay view with the results and also sets a status message.
fn execute_review_action(app: &mut App, action: QuickAction) {
    let result = (|| -> Result<()> {
        let pack = load_or_build_review_pack_for_tui(app)?;
        match action {
            QuickAction::RiskSummary | QuickAction::ReviewPack | QuickAction::Verify => {
                let verdict = verify_patch_risk(&pack);
                let msg = format!(
                    "Risk {:.2} / conf {:.2} ({})",
                    pack.risk_score,
                    pack.confidence,
                    verdict
                        .get("verdict")
                        .and_then(|v| v.as_str())
                        .unwrap_or("unknown")
                );
                app.set_status_message(format!("{}: {}", action.id(), msg));
                app.start_review_pack_view(pack, Some(verdict));
            }
            QuickAction::NextActions => {
                let actions = build_next_actions(&pack);
                let first = actions.first().map(|a| a.title.as_str()).unwrap_or("none");
                let msg = format!("Next actions: {} (top: {})", actions.len(), first);
                app.set_status_message(format!("{}: {}", action.id(), msg));
                app.start_next_actions_view(actions);
            }
            QuickAction::HandoffClaude => {
                let h = build_handoff_context(&pack, "claude");
                let msg = format!(
                    "Handoff ready for Claude ({} actions)",
                    h.next_actions.len()
                );
                app.set_status_message(format!("{}: {}", action.id(), msg));
                app.start_handoff_view(h);
            }
            QuickAction::HandoffCodex => {
                let h = build_handoff_context(&pack, "codex");
                let msg = format!("Handoff ready for Codex ({} actions)", h.next_actions.len());
                app.set_status_message(format!("{}: {}", action.id(), msg));
                app.start_handoff_view(h);
            }
            QuickAction::HandoffCopilot => {
                let h = build_handoff_context(&pack, "copilot");
                let msg = format!(
                    "Handoff ready for Copilot ({} actions)",
                    h.next_actions.len()
                );
                app.set_status_message(format!("{}: {}", action.id(), msg));
                app.start_handoff_view(h);
            }
            _ => unreachable!(),
        }
        Ok(())
    })();

    match result {
        Ok(()) => {
            let _ = record_quick_action_usage(action.id());
        }
        Err(e) => set_error_status(app, app.language.status_quick_action_failed(), &e),
    }
}