gitstack 5.3.0

Git history viewer with insights - Author stats, file heatmap, code ownership
Documentation
use std::collections::hash_map::DefaultHasher;
use std::fs;
use std::hash::{Hash, Hasher};
use std::path::PathBuf;

use anyhow::Result;
use git2::Repository;
use serde::{Deserialize, Serialize};

use crate::insights::ReviewPack;

const INDEX_VERSION: u8 = 1;

#[derive(Debug, Clone, Serialize, Deserialize)]
struct ReviewPackCacheEntry {
    version: u8,
    repo_id: String,
    head: String,
    selected_hash: Option<String>,
    pack: ReviewPack,
}

fn repo_id() -> Option<String> {
    let repo = Repository::discover(".").ok()?;
    let root = repo
        .workdir()
        .or_else(|| repo.path().parent())
        .unwrap_or(repo.path());
    let mut hasher = DefaultHasher::new();
    root.to_string_lossy().hash(&mut hasher);
    Some(format!("{:016x}", hasher.finish()))
}

fn current_head() -> Option<String> {
    let repo = Repository::discover(".").ok()?;
    let oid = repo.head().ok()?.target()?;
    Some(oid.to_string())
}

fn cache_dir() -> Option<PathBuf> {
    dirs::cache_dir().map(|p| p.join("gitstack").join("index-v1"))
}

fn review_pack_cache_path(selected_hash: Option<&str>) -> Option<PathBuf> {
    let rid = repo_id()?;
    let suffix = selected_hash.unwrap_or("none");
    let file_name = format!("review-pack-{}-{}.json", rid, suffix);
    Some(cache_dir()?.join(file_name))
}

pub fn load_review_pack(selected_hash: Option<&str>) -> Option<ReviewPack> {
    let path = review_pack_cache_path(selected_hash)?;
    let content = fs::read_to_string(path).ok()?;
    let entry: ReviewPackCacheEntry = serde_json::from_str(&content).ok()?;
    if entry.version != INDEX_VERSION {
        return None;
    }
    if entry.repo_id != repo_id()? {
        return None;
    }
    if entry.head != current_head()? {
        return None;
    }
    if entry.selected_hash.as_deref() != selected_hash {
        return None;
    }
    Some(entry.pack)
}

pub fn save_review_pack(selected_hash: Option<&str>, pack: &ReviewPack) -> Result<()> {
    let Some(path) = review_pack_cache_path(selected_hash) else {
        return Ok(());
    };

    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }

    let Some(repo_id) = repo_id() else {
        return Ok(());
    };
    let Some(head) = current_head() else {
        return Ok(());
    };

    let entry = ReviewPackCacheEntry {
        version: INDEX_VERSION,
        repo_id,
        head,
        selected_hash: selected_hash.map(str::to_string),
        pack: pack.clone(),
    };
    let json = serde_json::to_string_pretty(&entry)?;
    fs::write(path, json)?;
    Ok(())
}