git_snip/
lib.rs

1use std::collections::HashSet;
2use std::io;
3
4use git2::{BranchType, Repository};
5
6/// Delete a git branch from a given repository.
7///
8/// ## Errors
9///
10/// Return git2::Error if the delete operation fails.
11fn delete_branch(repo: &Repository, branch_name: &str) -> Result<(), git2::Error> {
12    let mut branch = repo.find_branch(branch_name, git2::BranchType::Local)?;
13    branch.delete()?;
14    println!("Deleting branch: {}", branch_name);
15    Ok(())
16}
17
18/// Return a list of all branches of a given BranchType in a repository as a
19/// HashSet. If the repository is not found, or there are no local branches,
20/// return an empty HashSet.
21fn list_branches(repo: &Repository, branch_type: BranchType) -> HashSet<String> {
22    let mut branches = HashSet::new();
23
24    if let Ok(local_branches) = repo.branches(Some(branch_type)) {
25        for (b, _) in local_branches.flatten() {
26            if let Ok(Some(name)) = b.name() {
27                branches.insert(name.to_string());
28            }
29        }
30    }
31    branches
32}
33
34/// Return Repository object from the current directory.
35fn open_repo() -> Repository {
36    Repository::open(".").expect("Failed to open repository.")
37}
38
39/// Normalize branch names by removing the prefix "origin/".
40fn normalize_branch_name(branch_name: &str) -> String {
41    branch_name.replace("origin/", "")
42}
43
44/// Delete all local branches that are not in remote branches.
45pub fn run(no_confirm: bool) {
46    let repo = crate::open_repo();
47    let local_branches = crate::list_branches(&repo, BranchType::Local);
48    let remote_branches: HashSet<_> = crate::list_branches(&repo, BranchType::Remote)
49        .iter()
50        .map(|b| normalize_branch_name(b))
51        .collect();
52
53    // Mark local branches that are not in remote branches to delete.
54    let branches_to_delete: Vec<_> = local_branches.difference(&remote_branches).collect();
55    if branches_to_delete.is_empty() {
56        println!("No local branches to delete.");
57        return;
58    }
59
60    if !no_confirm {
61        println!("Local branches to delete:");
62        for b in &branches_to_delete {
63            println!("- {}", b);
64        }
65
66        println!("Are you sure you want to delete these branches? (y/N)");
67        let mut user_input = String::new();
68        io::stdin()
69            .read_line(&mut user_input)
70            .expect("Could not read input.");
71        user_input = user_input.trim().to_lowercase();
72
73        if (user_input != "y") && (user_input != "yes") {
74            println!("Aborting.");
75            return;
76        }
77    }
78
79    for b in branches_to_delete {
80        crate::delete_branch(&repo, b).unwrap();
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    use git2::RepositoryInitOptions;
89    use tempfile::TempDir;
90
91    /// Create a mock Git repository with initial commit in a temporary
92    /// directory for testing.
93    fn create_mock_repo() -> (TempDir, Repository) {
94        let tempdir = TempDir::new().unwrap();
95        let mut opts = RepositoryInitOptions::new();
96        opts.initial_head("main");
97        let repo = Repository::init_opts(tempdir.path(), &opts).unwrap();
98
99        // Create initial commit
100        {
101            let mut config = repo.config().unwrap();
102            config.set_str("user.name", "name").unwrap();
103            config.set_str("user.email", "email").unwrap();
104            let mut index = repo.index().unwrap();
105            let id = index.write_tree().unwrap();
106
107            let tree = repo.find_tree(id).unwrap();
108            let sig = repo.signature().unwrap();
109            repo.commit(Some("HEAD"), &sig, &sig, "initial\n\nbody", &tree, &[])
110                .unwrap();
111        }
112        (tempdir, repo)
113    }
114
115    /// Find the latest commit in a repository.
116    fn get_latest_commit(repo: &Repository) -> git2::Commit {
117        let head = repo.head().unwrap();
118        let commit = head.peel_to_commit().unwrap();
119        commit
120    }
121
122    #[test]
123    fn test_delete_branch() {
124        // GIVEN a repository with a branch
125        let (_testdir, repo) = create_mock_repo();
126        let branch_name = "test-branch";
127        let target_commit = get_latest_commit(&repo);
128        let _ = repo.branch(branch_name, &target_commit, false);
129
130        // WHEN the branch is deleted
131        let _ = delete_branch(&repo, branch_name);
132
133        // THEN the branch should not exist
134        assert!(repo.find_branch(branch_name, BranchType::Local).is_err());
135    }
136
137    #[test]
138    fn test_list_branches_local() {
139        // GIVEN a repository with local branches
140        let (_testdir, repo) = create_mock_repo();
141        let target_commit = get_latest_commit(&repo);
142        let local_branches = vec!["local-branch-1", "local-branch-2"];
143        for branch_name in local_branches.iter() {
144            let _ = repo.branch(branch_name, &target_commit, false);
145        }
146
147        let mut expected = HashSet::from_iter(local_branches.iter().map(|b| b.to_string()));
148        expected.insert("main".to_string());
149
150        // WHEN the list of branches is retrieved
151        let actual = list_branches(&repo, BranchType::Local);
152
153        // THEN the set of branches should match the expected one
154        assert_eq!(actual, expected);
155    }
156
157    #[test]
158    fn test_normalize_branch_name() {
159        // GIVEN a branch name with the prefix "origin/"
160        let branch_name = "origin/feature/branch";
161
162        // WHEN the prefix is removed
163        let actual = normalize_branch_name(branch_name);
164
165        // THEN it should match the normalized branch name
166        let expected = "feature/branch";
167        assert_eq!(actual, expected);
168    }
169}