git-trim 0.4.4

Automatically trims your tracking branches whose upstream branches are merged or stray
Documentation
use std::convert::TryFrom;

use anyhow::{Context, Result};
use git2::{Branch, Config, Direction, Reference, Repository};
use log::*;
use thiserror::Error;

use crate::config;
use crate::simple_glob::{expand_refspec, ExpansionSide};

pub trait Refname {
    fn refname(&self) -> &str;
}

#[derive(Eq, PartialEq, Ord, PartialOrd, Debug, Hash, Clone)]
pub struct LocalBranch {
    pub refname: String,
}

impl LocalBranch {
    pub fn new(refname: &str) -> Self {
        assert!(refname.starts_with("refs/heads/"));
        Self {
            refname: refname.to_string(),
        }
    }

    pub fn short_name(&self) -> &str {
        &self.refname["refs/heads/".len()..]
    }

    pub fn fetch_upstream(
        &self,
        repo: &Repository,
        config: &Config,
    ) -> Result<RemoteTrackingBranchStatus> {
        let remote_name = if let Some(remote_name) = config::get_remote_name(config, self)? {
            remote_name
        } else {
            return Ok(RemoteTrackingBranchStatus::None);
        };
        let merge: String = if let Some(merge) = config::get_merge(config, self)? {
            merge
        } else {
            return Ok(RemoteTrackingBranchStatus::None);
        };

        RemoteTrackingBranch::from_remote_branch(
            repo,
            &RemoteBranch {
                remote: remote_name,
                refname: merge,
            },
        )
    }
}

impl Refname for LocalBranch {
    fn refname(&self) -> &str {
        &self.refname
    }
}

impl<'repo> TryFrom<&git2::Branch<'repo>> for LocalBranch {
    type Error = anyhow::Error;

    fn try_from(branch: &Branch<'repo>) -> Result<Self> {
        let refname = branch.get().name().context("non-utf8 branch ref")?;
        Ok(Self::new(refname))
    }
}

impl<'repo> TryFrom<&git2::Reference<'repo>> for LocalBranch {
    type Error = anyhow::Error;

    fn try_from(reference: &Reference<'repo>) -> Result<Self> {
        if !reference.is_branch() {
            anyhow::bail!("Reference {:?} is not a branch", reference.name());
        }

        let refname = reference.name().context("non-utf8 reference name")?;
        Ok(Self::new(refname))
    }
}

#[derive(Eq, PartialEq, Ord, PartialOrd, Debug, Hash, Clone)]
pub struct RemoteTrackingBranch {
    pub refname: String,
}

impl RemoteTrackingBranch {
    pub fn new(refname: &str) -> RemoteTrackingBranch {
        assert!(refname.starts_with("refs/remotes/"));
        RemoteTrackingBranch {
            refname: refname.to_string(),
        }
    }

    pub fn from_remote_branch(
        repo: &Repository,
        remote_branch: &RemoteBranch,
    ) -> Result<RemoteTrackingBranchStatus> {
        let remote = config::get_remote(repo, &remote_branch.remote)?;
        if let Some(remote) = remote {
            let refname = if let Some(expanded) = expand_refspec(
                &remote,
                &remote_branch.refname,
                Direction::Fetch,
                ExpansionSide::Right,
            )? {
                expanded
            } else {
                return Ok(RemoteTrackingBranchStatus::None);
            };

            if repo.find_reference(&refname).is_ok() {
                return Ok(RemoteTrackingBranchStatus::Exists(
                    RemoteTrackingBranch::new(&refname),
                ));
            } else {
                return Ok(RemoteTrackingBranchStatus::Gone(refname));
            }
        }
        Ok(RemoteTrackingBranchStatus::None)
    }

    pub fn to_remote_branch(
        &self,
        repo: &Repository,
    ) -> std::result::Result<RemoteBranch, RemoteBranchError> {
        for remote_name in repo.remotes()?.iter() {
            let remote_name = remote_name.context("non-utf8 remote name")?;
            let remote = repo.find_remote(remote_name)?;
            if let Some(expanded) = expand_refspec(
                &remote,
                &self.refname,
                Direction::Fetch,
                ExpansionSide::Left,
            )? {
                return Ok(RemoteBranch {
                    remote: remote.name().context("non-utf8 remote name")?.to_string(),
                    refname: expanded,
                });
            }
        }
        Err(RemoteBranchError::RemoteNotFound)
    }
}

impl Refname for RemoteTrackingBranch {
    fn refname(&self) -> &str {
        &self.refname
    }
}

impl<'repo> TryFrom<&git2::Branch<'repo>> for RemoteTrackingBranch {
    type Error = anyhow::Error;

    fn try_from(branch: &Branch<'repo>) -> Result<Self> {
        let refname = branch.get().name().context("non-utf8 branch ref")?;
        Ok(Self::new(refname))
    }
}

impl<'repo> TryFrom<&git2::Reference<'repo>> for RemoteTrackingBranch {
    type Error = anyhow::Error;

    fn try_from(reference: &Reference<'repo>) -> Result<Self> {
        if !reference.is_remote() {
            anyhow::bail!("Reference {:?} is not a branch", reference.name());
        }

        let refname = reference.name().context("non-utf8 reference name")?;
        Ok(Self::new(refname))
    }
}

pub enum RemoteTrackingBranchStatus {
    Exists(RemoteTrackingBranch),
    Gone(String),
    None,
}

#[derive(Eq, PartialEq, Ord, PartialOrd, Clone, Hash, Debug)]
pub struct RemoteBranch {
    pub remote: String,
    pub refname: String,
}

impl std::fmt::Display for RemoteBranch {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}, {}", self.remote, self.refname)
    }
}

#[derive(Error, Debug)]
pub enum RemoteBranchError {
    #[error("anyhow error")]
    AnyhowError(#[from] anyhow::Error),
    #[error("libgit2 internal error")]
    GitError(#[from] git2::Error),
    #[error("remote with matching refspec not found")]
    RemoteNotFound,
}