git-snip 0.3.0

Snip local Git branches that do not exist on the remote.
Documentation
use std::collections::HashSet;
use std::path::Path;

use anyhow::{Context, Result};
use git2::BranchType;

use crate::branch::Branch;
use crate::reference::Reference;
use crate::remote::Remote;

/// Result of querying for orphaned branches.
pub struct OrphanedBranches<'repo> {
    /// Branches that can be deleted.
    pub branches: Vec<Branch<'repo>>,
    /// Name of the current branch, if it was skipped from deletion.
    pub skipped_head: Option<String>,
}

/// Internal representation of a Git repository.
pub struct Repository {
    repository: git2::Repository,
}

impl Repository {
    /// Open a repository at the given path. Traverse the directory tree
    /// to find the repository root. Return Ok(Repository) if found, or
    /// Error if not found.
    ///
    /// # Errors
    /// Returns an error if the repository cannot be opened.
    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
        let repository =
            git2::Repository::discover(path).context("Failed to discover repository")?;
        Ok(Repository { repository })
    }

    /// Get path of the repository
    pub fn path(&self) -> &Path {
        self.repository.path()
    }

    /// Get current HEAD reference.
    pub fn head(&self) -> Result<Reference<'_>> {
        let head = self
            .repository
            .head()
            .context("Failed to get HEAD reference")?;
        Ok(Reference::from(head))
    }

    /// Return a list of all branches of a given BranchType in a repository as a Vec.
    pub fn branches(&self, branch_type: BranchType) -> Vec<Branch<'_>> {
        self.repository
            .branches(Some(branch_type))
            .ok()
            .into_iter()
            .flat_map(|branches_list| branches_list.filter_map(Result::ok))
            .map(|(branch, _)| branch.into())
            .collect()
    }

    /// Return all orphaned branches in a repository - branches without a
    /// remote counterpart. Also reports if the current HEAD branch was
    /// excluded from the result.
    pub fn orphaned_branches(&self) -> OrphanedBranches<'_> {
        let local_branches = self.branches(BranchType::Local);
        let remote_branches = self.branches(BranchType::Remote);
        let remote_prefixes = self
            .remotes()
            .iter()
            .map(|r| r.as_prefix())
            .collect::<HashSet<String>>();

        let remote_names: HashSet<String> = remote_branches
            .iter()
            .filter_map(|r| r.name_without_prefix(&remote_prefixes).ok())
            .collect();

        let mut branches = local_branches
            .into_iter()
            .filter(|b| b.name().ok().is_none_or(|n| !remote_names.contains(&n)))
            .collect::<Vec<Branch>>();

        // Check if the current branch is in the list of orphaned branches. If it is,
        // remove it from the list.
        let mut skipped_head = None;
        if let Ok(head_ref) = self.head() {
            if let Some(head_short) = head_ref.shorthand() {
                let initial_len = branches.len();
                branches.retain(|b| b.to_string() != head_short);
                if branches.len() < initial_len {
                    skipped_head = Some(head_short.to_string());
                }
            }
        }

        OrphanedBranches {
            branches,
            skipped_head,
        }
    }

    /// Get a list of remotes for a repository. If there are no remotes, return an
    /// empty Vec.
    pub fn remotes(&self) -> Vec<Remote> {
        let mut remotes = Vec::new();
        if let Ok(remote_list) = self.repository.remotes() {
            for remote in remote_list.iter().flatten() {
                remotes.push(Remote::new(remote));
            }
        }
        remotes
    }
}

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

    use std::fs;

    use crate::test_utilities;

    #[test]
    fn test_open_current() {
        // GIVEN a repository
        let (testdir, repo) = test_utilities::create_mock_repo();

        // WHEN the repository is opened
        let actual = Repository::open(testdir).unwrap();

        // THEN it should match the expected repository
        assert_eq!(actual.path(), repo.path());
    }

    #[test]
    fn test_open_parent() {
        // GIVEN a repository with a subdirectory
        let (testdir, repo) = test_utilities::create_mock_repo();
        let subdir = repo.path().join("subdir");
        fs::create_dir(&subdir).unwrap();

        // WHEN the repository is opened
        let actual = Repository::open(testdir).unwrap();

        // THEN it should match the expected repository
        assert_eq!(actual.path(), repo.path());
    }

    #[test]
    fn test_open_not_found() {
        // GIVEN a directory that is not a repository
        let testdir = tempfile::tempdir().unwrap();

        // WHEN the repository is opened
        let actual = Repository::open(testdir.path());

        // THEN it should not be found
        assert!(actual.is_err());
    }

    #[test]
    fn test_head() {
        // GIVEN a repository
        let (testdir, repo) = test_utilities::create_mock_repo();

        // WHEN getting the HEAD reference
        let binding = Repository::open(testdir.path()).unwrap();
        let actual = binding.head().unwrap();
        let expected = repo.head().unwrap();

        // THEN it should match the expected reference
        assert_eq!(actual.name(), expected.name());
    }

    #[test]
    fn test_list_local() {
        // GIVEN a repository with local branches
        let (testdir, repo) = test_utilities::create_mock_repo();
        let target_commit = test_utilities::get_latest_commit(&repo);
        let local_branches = vec!["local-branch-1", "local-branch-2"];
        for branch_name in local_branches.iter() {
            let _ = repo.branch(branch_name, &target_commit, false);
        }

        let mut expected = Vec::from_iter(local_branches.iter().map(|b| b.to_string()));
        expected.push("main".to_string());

        // WHEN the list of branches is retrieved
        let actual = Repository::open(testdir.path())
            .unwrap()
            .branches(BranchType::Local)
            .iter()
            .map(|b| b.to_string())
            .collect::<Vec<String>>();

        // THEN the set of branches should match the expected one
        assert_eq!(actual, expected);
    }

    #[test]
    fn test_orphaned_branches() {
        // GIVEN a repository with local and remote branches
        let (testdir, repo) = test_utilities::create_mock_repo();
        let target_commit = test_utilities::get_latest_commit(&repo);
        let orphaned_branches = vec!["to-delete-1", "to-delete-2"];
        for branch_name in orphaned_branches.iter() {
            let _ = repo.branch(branch_name, &target_commit, false);
        }

        // WHEN the list of orphaned branches is retrieved
        let repo = Repository::open(testdir.path()).unwrap();
        let result = repo.orphaned_branches();
        let actual: Vec<String> = result.branches.iter().map(|b| b.to_string()).collect();

        // THEN the set of branches should match the expected one
        assert_eq!(actual, orphaned_branches);
        // main is HEAD so it gets skipped
        assert_eq!(result.skipped_head.as_deref(), Some("main"));
    }

    #[test]
    fn test_path() {
        // GIVEN a repository
        let (testdir, repo) = test_utilities::create_mock_repo();

        // WHEN getting the path
        let binding = Repository::open(testdir).unwrap();
        let actual = binding.path();

        // THEN it should match the expected path
        assert_eq!(actual, repo.path());
    }

    #[test]
    fn test_remotes_one() {
        // GIVEN a repository with remote
        let (testdir, repo) = test_utilities::create_mock_repo();
        let remote_name = "origin";
        repo.remote(remote_name, "https://example.com").unwrap();
        let remote = Remote::new(remote_name);

        // WHEN the remotes are listed
        let actual = Repository::open(testdir).unwrap().remotes();

        // THEN it should match the expected remotes
        assert_eq!(actual.len(), 1);
        assert!(actual.contains(&remote));
    }

    #[test]
    fn test_remotes_multiple() {
        // GIVEN a repository with multiple remotes
        let (testdir, repo) = test_utilities::create_mock_repo();

        let remote1_name = "origin";
        repo.remote(remote1_name, "https://example.com").unwrap();
        let remote1 = Remote::new(remote1_name);

        let remote2_name = "upstream";
        repo.remote(remote2_name, "https://example.org").unwrap();
        let remote2 = Remote::new(remote2_name);

        // WHEN the remotes are listed
        let actual = Repository::open(testdir).unwrap().remotes();

        // THEN it should match the expected remotes
        assert_eq!(actual.len(), 2);
        assert!(actual.contains(&remote1));
        assert!(actual.contains(&remote2));
    }

    #[test]
    fn test_remotes_empty() {
        // GIVEN a repository without remotes
        let (testdir, _repo) = crate::test_utilities::create_mock_repo();

        // WHEN the remote prefixes are listed
        let actual = Repository::open(testdir).unwrap().remotes();

        // THEN it should be empty
        assert!(actual.is_empty());
    }
}