git-ward 0.2.0

Proof-before-delete archival for local Git repositories
use anyhow::Result;
use chrono::Utc;
use colored::Colorize;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

use crate::config::Thresholds;
use crate::git;
use crate::util::{dir_size, format_size};

#[derive(Clone, Serialize, Deserialize)]
pub struct Assessment {
    pub path: PathBuf,
    pub size: u64,
    pub last_commit: Option<chrono::NaiveDate>,
    pub first_commit: Option<chrono::NaiveDate>,
    pub head_sha: Option<String>,
    pub commit_count: u64,
    pub author_count: u64,
    pub origin_url: Option<String>,
    pub remotes: Vec<(String, String)>,
    pub branches: Vec<git::BranchStatus>,
    pub tag_count: u64,
    pub stash_count: u64,
    pub untracked: u64,
    pub ignored: u64,
    pub dirty: bool,
    pub worktree_count: u64,
    pub submodule_count: u64,
    pub verdict: Verdict,
}

#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Verdict {
    Archive,
    Prototype,
    Worktree,
    HasLocalWork,
    KeepAsIs,
    NoRemote,
}

impl Verdict {
    pub fn label(&self) -> colored::ColoredString {
        match self {
            Verdict::Archive => "ARCHIVE".green().bold(),
            Verdict::Prototype => "PROTOTYPE".cyan().bold(),
            Verdict::Worktree => "WORKTREE".blue().bold(),
            Verdict::HasLocalWork => "LOCAL-WORK".yellow().bold(),
            Verdict::KeepAsIs => "KEEP".white().bold(),
            Verdict::NoRemote => "NO-REMOTE".magenta().bold(),
        }
    }
}

pub fn assess_repo(path: &Path, thresholds: &Thresholds) -> Result<Assessment> {
    let size = dir_size(path);
    let last_commit = git::last_commit_date(path);
    let first_commit = git::first_commit_date(path);
    let head_sha = git::head_sha(path);
    let commit_count = git::commit_count(path);
    let author_count = git::author_count(path);
    let origin_url = git::origin_url(path);
    let remotes = git::remote_urls(path);
    let branches = git::branches(path);
    let tag_count = git::tag_count(path);
    let stash_count = git::stash_count(path);
    let (untracked, ignored) = git::untracked_count(path).unwrap_or((0, 0));
    let dirty = git::has_uncommitted_changes(path).unwrap_or(true);
    let worktree_count = git::worktree_paths(path).len().saturating_sub(1) as u64;
    let submodule_count = git::submodule_count(path);

    let all_pushed = branches.iter().all(|b| b.upstream.is_some() && b.ahead == 0);
    let has_local_only = branches.iter().any(|b| b.upstream.is_none());
    let has_ahead = branches.iter().any(|b| b.ahead > 0);

    let age_days = last_commit.map(|d| {
        (Utc::now().date_naive() - d).num_days()
    });
    let lifetime_days = match (first_commit, last_commit) {
        (Some(f), Some(l)) => Some((l - f).num_days()),
        _ => None,
    };

    let verdict = classify(
        &RepoFacts {
            has_remote: origin_url.is_some(),
            all_pushed,
            has_local_only,
            has_ahead,
            dirty,
            commit_count,
            author_count,
            lifetime_days,
            age_days,
        },
        thresholds,
    );

    Ok(Assessment {
        path: path.to_path_buf(),
        size,
        last_commit,
        first_commit,
        head_sha,
        commit_count,
        author_count,
        origin_url,
        remotes,
        branches,
        tag_count,
        stash_count,
        untracked,
        ignored,
        dirty,
        worktree_count,
        submodule_count,
        verdict,
    })
}

struct RepoFacts {
    has_remote: bool,
    all_pushed: bool,
    has_local_only: bool,
    has_ahead: bool,
    dirty: bool,
    commit_count: u64,
    author_count: u64,
    lifetime_days: Option<i64>,
    age_days: Option<i64>,
}

fn classify(f: &RepoFacts, t: &Thresholds) -> Verdict {
    let is_prototype = |with_commits_lower_bound: bool| {
        let has_min_commits = if with_commits_lower_bound {
            f.commit_count > 0
        } else {
            true
        };
        has_min_commits
            && f.commit_count < t.prototype_max_commits
            && f.author_count <= t.prototype_max_authors
            && f.lifetime_days
                .map(|d| d < t.prototype_max_lifetime_days)
                .unwrap_or(false)
    };

    if f.dirty {
        return Verdict::HasLocalWork;
    }
    if !f.has_remote {
        if is_prototype(true) {
            return Verdict::Prototype;
        }
        return Verdict::NoRemote;
    }
    if f.has_ahead || f.has_local_only {
        return Verdict::HasLocalWork;
    }
    if f.all_pushed {
        let old = f.age_days.map(|d| d > t.archive_stale_days).unwrap_or(false);
        if is_prototype(false) {
            return Verdict::Prototype;
        }
        if old {
            return Verdict::Archive;
        }
        return Verdict::KeepAsIs;
    }
    Verdict::HasLocalWork
}

pub fn print_safety_proof(a: &Assessment) {
    let remote_line = match a.origin_url.as_deref() {
        Some(u) => u.to_string(),
        None => "none".to_string(),
    };
    let local_only: Vec<_> = a
        .branches
        .iter()
        .filter(|b| b.upstream.is_none())
        .map(|b| b.name.clone())
        .collect();
    let ahead_branches: Vec<_> = a
        .branches
        .iter()
        .filter(|b| b.ahead > 0)
        .map(|b| format!("{} (+{} ahead)", b.name, b.ahead))
        .collect();

    println!("    {}", "Safety proof".bold());
    println!("      remote           {}", remote_line.dimmed());
    println!("      head             {}", short_sha(&a.head_sha).dimmed());
    println!(
        "      commits          {} across {} author(s)",
        a.commit_count,
        a.author_count
    );
    println!(
        "      branches         {} ({} local-only, {} ahead)",
        a.branches.len(),
        local_only.len(),
        ahead_branches.len()
    );
    if !local_only.is_empty() {
        println!("      local-only refs  {}", local_only.join(", ").yellow());
    }
    if !ahead_branches.is_empty() {
        println!("      ahead branches   {}", ahead_branches.join(", ").yellow());
    }
    println!("      uncommitted      {}", yes_no(a.dirty, true));
    println!("      stashes          {}", count_colour(a.stash_count, true));
    println!("      tags             {}", a.tag_count.to_string().dimmed());
    println!(
        "      untracked        {} (ignored {})",
        count_colour(a.untracked, true),
        a.ignored.to_string().dimmed()
    );
    println!("      worktrees        {}", a.worktree_count.to_string().dimmed());
    if a.submodule_count > 0 {
        println!(
            "      submodules       {}",
            a.submodule_count.to_string().yellow().bold()
        );
    }
    println!("      size             {}", format_size(a.size).bold());
}

fn short_sha(sha: &Option<String>) -> String {
    sha.as_deref()
        .map(|s| s.chars().take(10).collect())
        .unwrap_or_else(|| "unknown".to_string())
}

fn yes_no(flag: bool, warn_when_true: bool) -> colored::ColoredString {
    if flag {
        if warn_when_true {
            "yes".red().bold()
        } else {
            "yes".green().bold()
        }
    } else if warn_when_true {
        "no".green()
    } else {
        "no".red()
    }
}

fn count_colour(n: u64, warn_when_nonzero: bool) -> colored::ColoredString {
    if n == 0 {
        "0".green()
    } else if warn_when_nonzero {
        n.to_string().red().bold()
    } else {
        n.to_string().yellow()
    }
}