1use std::collections::HashSet;
2use std::io;
3
4use git2::{BranchType, Repository};
5
6fn 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
18fn 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
34fn open_repo() -> Repository {
36 Repository::open(".").expect("Failed to open repository.")
37}
38
39fn normalize_branch_name(branch_name: &str) -> String {
41 branch_name.replace("origin/", "")
42}
43
44pub 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 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 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 {
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 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 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 let _ = delete_branch(&repo, branch_name);
132
133 assert!(repo.find_branch(branch_name, BranchType::Local).is_err());
135 }
136
137 #[test]
138 fn test_list_branches_local() {
139 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 let actual = list_branches(&repo, BranchType::Local);
152
153 assert_eq!(actual, expected);
155 }
156
157 #[test]
158 fn test_normalize_branch_name() {
159 let branch_name = "origin/feature/branch";
161
162 let actual = normalize_branch_name(branch_name);
164
165 let expected = "feature/branch";
167 assert_eq!(actual, expected);
168 }
169}