git-broom 0.1.1

Interactive TUI for cleaning up stale local git branches
Documentation
use std::collections::{BTreeMap, BTreeSet};
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};

use crate::app::CleanupMode;

const KEEP_STORE_VERSION: u32 = 1;

#[derive(Debug, Clone)]
pub struct KeepStore {
    path: PathBuf,
    labels_by_mode: BTreeMap<String, BTreeSet<String>>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
struct KeepStoreFile {
    version: u32,
    labels_by_mode: BTreeMap<String, BTreeSet<String>>,
}

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

        let contents = fs::read_to_string(&path)
            .with_context(|| format!("failed to read keep-label store {}", path.display()))?;
        let parsed = serde_json::from_str::<KeepStoreFile>(&contents)
            .with_context(|| format!("failed to parse keep-label store {}", path.display()))?;

        if parsed.version != KEEP_STORE_VERSION {
            bail!(
                "unsupported keep-label store version {} in {}",
                parsed.version,
                path.display()
            );
        }

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

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

    pub fn is_saved(&self, mode: CleanupMode, branch: &str) -> bool {
        self.labels_by_mode
            .get(mode.key())
            .is_some_and(|branches| branches.contains(branch))
    }

    pub fn replace_mode<I, S>(&mut self, mode: CleanupMode, branches: I) -> Result<()>
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        let saved = branches
            .into_iter()
            .map(Into::into)
            .collect::<BTreeSet<_>>();
        if saved.is_empty() {
            self.labels_by_mode.remove(mode.key());
        } else {
            self.labels_by_mode.insert(mode.key().to_string(), saved);
        }

        self.persist()
    }

    fn persist(&self) -> Result<()> {
        if self.labels_by_mode.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 keep-label store {}", self.path.display())
                    });
                }
            }
            return Ok(());
        }

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

        let file = KeepStoreFile {
            version: KEEP_STORE_VERSION,
            labels_by_mode: self.labels_by_mode.clone(),
        };
        let contents =
            serde_json::to_string_pretty(&file).context("failed to serialize keep-label store")?;
        fs::write(&self.path, format!("{contents}\n"))
            .with_context(|| format!("failed to write keep-label store {}", self.path.display()))
    }
}

fn keep_store_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/keep-labels.json"))
}