git-snip 0.2.0

Snip local Git branches that do not exist on the remote.
Documentation
use std::collections::HashSet;
use std::fs::{File, Permissions};
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;

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

use crate::branch::Branch;
use crate::hook::{GitHook, GitHookType};
use crate::reference::Reference;
use crate::remote::Remote;

/// 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 })
    }

    /// Install a hook script in the repository.
    pub fn install_hook(&self, hook: &GitHook, hook_type: GitHookType) -> Result<()> {
        let hook_dir = self.path().join("hooks");
        let hook_path = hook_dir.join(hook_type.to_string());

        // Return error if the hook already exists.
        // This is to prevent overwriting existing hooks.
        if hook_path.exists() {
            bail!("Hook already exists at {}", hook_path.to_string_lossy());
        }

        let mut file = File::create(&hook_path).context("Failed to create hook file")?;
        file.write_all(hook.as_bytes())
            .context("Failed to write hook script")?;
        file.set_permissions(Permissions::from_mode(0o755))
            .context("Failed to set permissions on hook file")?;

        Ok(())
    }

    /// 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 a list of all orphaned branches in a repository - branches
    /// without a remote counterpart. If there are no orphaned branches, return
    /// an empty Vec.
    pub fn orphaned_branches(&self) -> Vec<Branch> {
        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>>();

        // Compare names of local branches with names of remote branches without
        // prefixes. Any local branches without a remote counterpart are considered
        // orphaned.
        let mut orphaned_branches = local_branches
            .into_iter()
            .filter(|b| {
                let branch_name = b.name();
                !remote_branches.iter().any(|r| {
                    r.name_without_prefix(&remote_prefixes).as_ref().ok()
                        == branch_name.as_ref().ok()
                })
            })
            .collect::<Vec<Branch>>();

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

    /// 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_git_hook_install() {
        // GIVEN a hook path and a hook script
        let (_testdir, repo) = test_utilities::create_mock_repo();
        let mock_script = String::from("echo 'Hello, world!'");

        // WHEN installing the hook
        let hook = GitHook::new(mock_script);
        let result = Repository::open(repo.path())
            .unwrap()
            .install_hook(&hook, GitHookType::PostMerge);

        // THEN the installation should be successful
        assert!(result.is_ok());
        let hook_path = &repo
            .path()
            .to_path_buf()
            .join("hooks")
            .join(GitHookType::PostMerge.to_string());
        let hook_script = std::fs::read_to_string(hook_path).unwrap();
        assert_eq!(hook_script, hook.into_string());
    }

    #[test]
    fn test_git_hook_install_already_exists() {
        // GIVEN an existing hook
        let (testdir, repo) = test_utilities::create_mock_repo();
        let hook_path = &repo
            .path()
            .to_path_buf()
            .join("hooks")
            .join(GitHookType::PostMerge.to_string());
        let _ = File::create(&hook_path).unwrap();

        // WHEN installing the hook
        let hook = GitHook::default();
        let result = Repository::open(testdir)
            .unwrap()
            .install_hook(&hook, GitHookType::PostMerge);

        // THEN the installation should fail
        assert!(result.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 orphanes branches is retrieved
        let actual = Repository::open(testdir.path())
            .unwrap()
            .orphaned_branches()
            .iter()
            .map(|b| b.to_string())
            .collect::<Vec<String>>();

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

    #[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());
    }
}