git-broom 0.1.1

Interactive TUI for cleaning up stale local git branches
Documentation
use std::collections::BTreeMap;
use std::fs;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};

use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};

const PR_CACHE_VERSION: u32 = 1;

#[derive(Debug, Clone)]
pub struct PrCache {
    path: PathBuf,
    entries_by_remote: BTreeMap<String, PrCacheRemoteEntry>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PrCacheRemoteEntry {
    pub remote_url: String,
    pub refreshed_at: i64,
    pub default_branch: String,
    pub pull_requests_by_head: BTreeMap<String, CachedPullRequestRecord>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CachedPullRequestRecord {
    pub state: String,
    pub url: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
struct PrCacheFile {
    version: u32,
    entries_by_remote: BTreeMap<String, PrCacheRemoteEntry>,
}

impl PrCache {
    pub fn new(repo: &Path) -> Result<Self> {
        Ok(Self {
            path: pr_cache_path(repo)?,
            entries_by_remote: BTreeMap::new(),
        })
    }

    pub fn load(repo: &Path) -> Result<Self> {
        let path = pr_cache_path(repo)?;
        if !path.exists() {
            return Ok(Self {
                path,
                entries_by_remote: BTreeMap::new(),
            });
        }

        let contents = fs::read_to_string(&path)
            .with_context(|| format!("failed to read PR cache {}", path.display()))?;
        let parsed = serde_json::from_str::<PrCacheFile>(&contents)
            .with_context(|| format!("failed to parse PR cache {}", path.display()))?;

        if parsed.version != PR_CACHE_VERSION {
            bail!(
                "unsupported PR cache version {} in {}",
                parsed.version,
                path.display()
            );
        }

        Ok(Self {
            path,
            entries_by_remote: parsed.entries_by_remote,
        })
    }

    pub fn path(&self) -> &Path {
        &self.path
    }

    pub fn remote_entry(&self, remote: &str, remote_url: &str) -> Option<&PrCacheRemoteEntry> {
        self.entries_by_remote
            .get(remote)
            .filter(|entry| entry.remote_url == remote_url)
    }

    pub fn replace_remote(&mut self, remote: &str, entry: PrCacheRemoteEntry) -> Result<()> {
        self.entries_by_remote.insert(remote.to_string(), entry);
        self.persist()
    }

    fn persist(&self) -> Result<()> {
        if self.entries_by_remote.is_empty() {
            match fs::remove_file(&self.path) {
                Ok(()) => {}
                Err(error) if error.kind() == ErrorKind::NotFound => {}
                Err(error) => {
                    return Err(error).with_context(|| {
                        format!("failed to remove PR cache {}", self.path.display())
                    });
                }
            }
            return Ok(());
        }

        let parent = self
            .path
            .parent()
            .context("PR cache path missing parent directory")?;
        fs::create_dir_all(parent)
            .with_context(|| format!("failed to create PR cache dir {}", parent.display()))?;

        let file = PrCacheFile {
            version: PR_CACHE_VERSION,
            entries_by_remote: self.entries_by_remote.clone(),
        };
        let contents =
            serde_json::to_string_pretty(&file).context("failed to serialize PR cache")?;
        fs::write(&self.path, format!("{contents}\n"))
            .with_context(|| format!("failed to write PR cache {}", self.path.display()))
    }
}

fn pr_cache_path(repo: &Path) -> Result<PathBuf> {
    let output = Command::new("git")
        .args(["rev-parse", "--path-format=absolute", "--git-common-dir"])
        .current_dir(repo)
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .output()
        .context("failed to resolve git common dir")?;

    if !output.status.success() {
        bail!(
            "failed to resolve git common dir: {}",
            String::from_utf8_lossy(&output.stderr).trim()
        );
    }

    let common_dir =
        String::from_utf8(output.stdout).context("git returned non-utf8 common dir output")?;
    Ok(PathBuf::from(common_dir.trim()).join("git-broom/pr-cache.json"))
}