git-snip 0.2.0

Snip local Git branches that do not exist on the remote.
Documentation
use std::collections::HashSet;
use std::fmt::{Debug, Display};

use anyhow::{bail, Context, Result};
use git2::Reference;

/// A wrapper around git2::Branch.
pub struct Branch<'a>(git2::Branch<'a>);

impl Branch<'_> {
    /// Delete the git branch.
    ///
    /// ## Errors
    ///
    /// Returns GitSnipError::DeleteBranchError if the branch cannot be deleted.
    pub fn delete(&mut self) -> Result<()> {
        self.0.delete().context("Failed to delete branch")
    }

    /// Return the name of the branch.
    pub fn name(&self) -> Result<String> {
        match self.0.name() {
            Ok(Some(name)) => Ok(name.to_string()),
            Ok(None) => bail!("Branch has no name"),
            Err(e) => bail!("Failed to get branch name: {}", e),
        }
    }

    /// Remove a branch name prefix from a list of possible prefixes.
    /// If none match, return the original branch name.
    pub fn name_without_prefix(&self, prefixes: &HashSet<String>) -> Result<String> {
        let name = self.name().context("Branch has no name")?;
        for prefix in prefixes {
            if name.starts_with(prefix) {
                return Ok(name[prefix.len()..].to_string());
            }
        }
        Ok(name)
    }
}

impl<'a> From<git2::Branch<'a>> for Branch<'a> {
    fn from(branch: git2::Branch<'a>) -> Self {
        Branch(branch)
    }
}

// Optionally, allow conversion from your own Reference wrapper if desired.
impl<'a> From<Reference<'a>> for Branch<'a> {
    fn from(reference: Reference<'a>) -> Self {
        Branch(git2::Branch::wrap(reference))
    }
}

impl PartialEq for Branch<'_> {
    fn eq(&self, other: &Self) -> bool {
        self.name().ok() == other.name().ok()
    }
}

impl Eq for Branch<'_> {}

impl Debug for Branch<'_> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Branch")
            .field("name", &self.name())
            .finish()
    }
}

impl Display for Branch<'_> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self.name() {
            Ok(name) => write!(f, "{name}"),
            Err(_) => write!(f, "<invalid branch>"),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_utilities;

    #[test]
    fn test_name() {
        // GIVEN a mock repository with a branch
        let (_testdir, repo) = test_utilities::create_mock_repo();
        let branch_name = "test-branch";
        let target_commit = test_utilities::get_latest_commit(&repo);
        let branch = repo.branch(branch_name, &target_commit, false).unwrap();

        // WHEN we get the name of the branch
        let actual = Branch::from(branch).name().unwrap();

        // THEN the name should match the branch name
        assert_eq!(actual, branch_name);
    }

    #[test]
    fn test_name_without_prefix() {
        // GIVEN a mock repository with a branch that has a remote prefix
        let (_testdir, repo) = test_utilities::create_mock_repo();
        let branch_name = "origin/test-branch";
        let target_commit = test_utilities::get_latest_commit(&repo);
        let branch = repo.branch(branch_name, &target_commit, false).unwrap();

        // WHEN we get the name without the remote prefix
        let remote_prefixes = HashSet::from_iter(vec!["origin/".to_string()]);
        let actual = Branch::from(branch)
            .name_without_prefix(&remote_prefixes)
            .unwrap();

        // THEN the name should be the branch name without the prefix
        assert_eq!(actual, "test-branch");
    }

    #[test]
    fn test_name_without_prefix_no_prefix() {
        // GIVEN a mock repository with a branch that does not have a remote prefix
        let (_testdir, repo) = test_utilities::create_mock_repo();
        let branch_name = "test-branch";
        let target_commit = test_utilities::get_latest_commit(&repo);
        let branch = repo.branch(branch_name, &target_commit, false).unwrap();

        // WHEN we get the name without the remote prefix
        let remote_prefixes = HashSet::from_iter(vec!["origin/".to_string()]);
        let actual = Branch::from(branch)
            .name_without_prefix(&remote_prefixes)
            .unwrap();

        // THEN the name should be the branch name as it has no prefix
        assert_eq!(actual, "test-branch");
    }

    #[test]
    fn test_delete() {
        // GIVEN a mock repository with a branch
        let (_testdir, repo) = test_utilities::create_mock_repo();
        let branch_name = "test-branch";
        let target_commit = test_utilities::get_latest_commit(&repo);
        let branch = repo.branch(branch_name, &target_commit, false);

        // WHEN we delete the branch
        let mut branch = Branch::from(branch.unwrap());
        let _ = branch.delete();

        // THEN the branch should no longer exist
        assert!(repo
            .find_branch(branch_name, git2::BranchType::Local)
            .is_err());
    }

    #[test]
    fn test_branch_equality() {
        // GIVEN a mock repository with two branches of the same name
        let (_testdir, repo) = test_utilities::create_mock_repo();
        let branch_name = "test-branch";
        let target_commit = test_utilities::get_latest_commit(&repo);

        // WHEN we create two branches with the same name
        let branch1 = repo.branch(branch_name, &target_commit, false).unwrap();
        let branch2 = repo
            .find_branch(branch_name, git2::BranchType::Local)
            .unwrap();

        // THEN both branches should be equal
        assert_eq!(Branch::from(branch1), Branch::from(branch2));
    }
}