1
use std::collections::HashSet;
2

            
3
use git2::{BranchType, Repository};
4

            
5
use crate::remote;
6

            
7
/// Get the current branch name of a repository.
8
4
fn current(repo: &Repository) -> Option<String> {
9
4
    let head = repo.head().ok()?;
10
4
    let shorthand = head.shorthand()?;
11
4
    Some(shorthand.to_string())
12
4
}
13

            
14
/// Delete a git branch from a given repository.
15
///
16
/// ## Errors
17
///
18
/// Return git2::Error if the delete operation fails.
19
2
pub fn delete(repo: &Repository, branch_name: &str) -> Result<(), git2::Error> {
20
2
    let mut branch = repo.find_branch(branch_name, git2::BranchType::Local)?;
21
2
    branch.delete()?;
22
2
    println!("Deleting branch: {}", branch_name);
23
2
    Ok(())
24
2
}
25

            
26
/// Return a list of all branches of a given BranchType in a repository as a
27
/// HashSet. If the repository is not found, or there are no local branches,
28
/// return an empty HashSet.
29
6
fn list(repo: &Repository, branch_type: BranchType) -> HashSet<String> {
30
6
    let mut branches = HashSet::new();
31

            
32
6
    if let Ok(local_branches) = repo.branches(Some(branch_type)) {
33
12
        for (b, _) in local_branches.flatten() {
34
12
            if let Some(name) = b.into_reference().shorthand() {
35
12
                branches.insert(name.to_string());
36
12
            }
37
        }
38
    }
39
6
    branches
40
6
}
41

            
42
/// Remove the remote branch prefix from a branch name.
43
/// If the branch name does not contain a remote prefix, return the original
44
/// branch name.
45
2
fn remove_remote_prefix<'a>(branch_name: &'a str, remote_prefixes: &HashSet<String>) -> &'a str {
46
2
    for prefix in remote_prefixes {
47
2
        if branch_name.starts_with(prefix) {
48
2
            return &branch_name[prefix.len()..];
49
        }
50
    }
51
    branch_name
52
2
}
53

            
54
/// Return a list all branches to delete from a repository - branches without a
55
/// remote counterpart. If there are no branches to delete, return an empty
56
/// HashSet.
57
2
pub fn list_to_delete(repo: &Repository) -> HashSet<String> {
58
2
    let local_branches = list(repo, BranchType::Local);
59
2
    let remote_branches: HashSet<_> = list(repo, BranchType::Remote)
60
2
        .iter()
61
2
        .map(|b| remove_remote_prefix(b, &remote::list_remote_prefixes(repo)).to_string())
62
2
        .collect();
63
2

            
64
2
    // Mark local branches that are not in remote branches to delete.
65
2
    let mut branches_to_delete: HashSet<String> = local_branches
66
2
        .difference(&remote_branches)
67
7
        .map(|b| b.to_string())
68
2
        .collect();
69
2

            
70
2
    let current_branch = current(repo);
71
2
    if let Some(current_branch) = current_branch {
72
2
        if branches_to_delete.contains(&current_branch) {
73
2
            println!("Cannot delete current branch: {}", current_branch);
74
2
            // Remove current branch from the list of branches to delete.
75
7
            branches_to_delete.retain(|b| b != &current_branch);
76
2
        }
77
    }
78
2
    branches_to_delete
79
2
}
80

            
81
#[cfg(test)]
82
mod tests {
83
    use super::*;
84

            
85
    use crate::test_utilities;
86

            
87
    /// Find the latest commit in a repository.
88
6
    fn get_latest_commit(repo: &Repository) -> git2::Commit {
89
6
        let head = repo.head().unwrap();
90
6
        let commit = head.peel_to_commit().unwrap();
91
6
        commit
92
6
    }
93

            
94
    #[test]
95
2
    fn test_current() {
96
2
        // GIVEN a repository with a branch
97
2
        let (_testdir, repo) = test_utilities::create_mock_repo();
98
2

            
99
2
        // WHEN the current branch is retrieved
100
2
        let actual = current(&repo).unwrap();
101
2
        let binding = repo.head().unwrap();
102
2
        let expected = binding.shorthand().unwrap();
103
2

            
104
2
        // THEN it should match the expected branch name
105
2
        assert_eq!(actual, expected);
106
2
    }
107

            
108
    #[test]
109
2
    fn test_delete() {
110
2
        // GIVEN a repository with a branch
111
2
        let (_testdir, repo) = test_utilities::create_mock_repo();
112
2
        let branch_name = "test-branch";
113
2
        let target_commit = get_latest_commit(&repo);
114
2
        let _ = repo.branch(branch_name, &target_commit, false);
115
2

            
116
2
        // WHEN the branch is deleted
117
2
        let _ = delete(&repo, branch_name);
118
2

            
119
2
        // THEN the branch should not exist
120
2
        assert!(repo.find_branch(branch_name, BranchType::Local).is_err());
121
2
    }
122

            
123
    #[test]
124
2
    fn test_list_local() {
125
2
        // GIVEN a repository with local branches
126
2
        let (_testdir, repo) = test_utilities::create_mock_repo();
127
2
        let target_commit = get_latest_commit(&repo);
128
2
        let local_branches = vec!["local-branch-1", "local-branch-2"];
129
4
        for branch_name in local_branches.iter() {
130
4
            let _ = repo.branch(branch_name, &target_commit, false);
131
4
        }
132

            
133
5
        let mut expected = HashSet::from_iter(local_branches.iter().map(|b| b.to_string()));
134
2
        expected.insert("main".to_string());
135
2

            
136
2
        // WHEN the list of branches is retrieved
137
2
        let actual = list(&repo, BranchType::Local);
138
2

            
139
2
        // THEN the set of branches should match the expected one
140
2
        assert_eq!(actual, expected);
141
2
    }
142

            
143
    #[test]
144
2
    fn test_list_to_delete() {
145
2
        // GIVEN a repository with local and remote branches
146
2
        let (_testdir, repo) = test_utilities::create_mock_repo();
147
2
        let target_commit = get_latest_commit(&repo);
148
2
        let orphaned_branches = vec!["to-delete-1", "to-delete-2"];
149
4
        for branch_name in orphaned_branches.iter() {
150
4
            let _ = repo.branch(branch_name, &target_commit, false);
151
4
        }
152

            
153
        // WHEN the list of branches to delete is retrieved
154
5
        let expected = HashSet::from_iter(orphaned_branches.iter().map(|b| b.to_string()));
155
2
        let actual = list_to_delete(&repo);
156
2

            
157
2
        // THEN the set of branches should match the expected one
158
2
        assert_eq!(actual, expected);
159
2
    }
160

            
161
    #[test]
162
2
    fn test_remove_remote_prefix() {
163
2
        // GIVEN a branch name with a remote prefix
164
2
        let branch_name = "origin/branch-name";
165
2
        let remote_prefixes = HashSet::from_iter(vec!["origin/".to_string()]);
166
2

            
167
2
        // WHEN the remote prefix is removed
168
2
        let actual = remove_remote_prefix(branch_name, &remote_prefixes);
169
2

            
170
2
        // THEN the branch name should match the expected one
171
2
        assert_eq!(actual, "branch-name");
172
2
    }
173
}