1use crate::commit_shared::{git_output, git_status_success, git_stdout_trimmed};
2use crate::prompt;
3use std::collections::HashSet;
4use std::process::Output;
5
6pub fn dispatch(cmd: &str, args: &[String]) -> Option<i32> {
7 match cmd {
8 "cleanup" | "delete-merged" => Some(run_cleanup(args)),
9 _ => None,
10 }
11}
12
13struct CleanupArgs {
14 base_ref: String,
15 squash_mode: bool,
16 help: bool,
17}
18
19fn run_cleanup(args: &[String]) -> i32 {
20 let parsed = match parse_args(args) {
21 Ok(value) => value,
22 Err(code) => return code,
23 };
24
25 if parsed.help {
26 print_help();
27 return 0;
28 }
29
30 if !git_status_success(&["rev-parse", "--is-inside-work-tree"]) {
31 eprintln!("❌ Not in a git repository");
32 return 1;
33 }
34
35 let base_ref = parsed.base_ref;
36 let squash_mode = parsed.squash_mode;
37
38 if !git_status_success(&["rev-parse", "--verify", "--quiet", &base_ref]) {
39 eprintln!("❌ Invalid base ref: {base_ref}");
40 return 1;
41 }
42
43 let base_commit = match git_stdout_trimmed(&["rev-parse", &format!("{base_ref}^{{commit}}")]) {
44 Ok(value) => value,
45 Err(_) => {
46 eprintln!("❌ Unable to resolve base commit: {base_ref}");
47 return 1;
48 }
49 };
50
51 let head_commit = match git_stdout_trimmed(&["rev-parse", "HEAD"]) {
52 Ok(value) => value,
53 Err(_) => {
54 eprintln!("❌ Unable to resolve HEAD commit");
55 return 1;
56 }
57 };
58
59 let delete_flag = if base_commit != head_commit {
60 "-D"
61 } else {
62 "-d"
63 };
64
65 let current_branch = match git_stdout_trimmed(&["rev-parse", "--abbrev-ref", "HEAD"]) {
66 Ok(value) => value,
67 Err(_) => {
68 eprintln!("❌ Unable to resolve current branch");
69 return 1;
70 }
71 };
72
73 let mut protected: HashSet<String> = ["main", "master", "develop", "trunk"]
74 .iter()
75 .map(|name| (*name).to_string())
76 .collect();
77
78 if current_branch != "HEAD" {
79 protected.insert(current_branch.clone());
80 }
81 protected.insert(base_ref.clone());
82
83 if let Some(base_local) = resolve_base_local(&base_ref) {
84 protected.insert(base_local);
85 }
86
87 let merged_branches = match git_output(&[
88 "for-each-ref",
89 "--merged",
90 &base_ref,
91 "--format=%(refname:short)",
92 "refs/heads",
93 ]) {
94 Ok(output) => parse_lines(&output),
95 Err(err) => {
96 eprintln!("{err:#}");
97 return 1;
98 }
99 };
100
101 let mut merged_set: HashSet<String> = HashSet::new();
102 for branch in &merged_branches {
103 merged_set.insert(branch.clone());
104 }
105
106 if !squash_mode && merged_branches.is_empty() {
107 println!("✅ No merged local branches found.");
108 return 0;
109 }
110
111 let mut candidates: Vec<String> = Vec::new();
112
113 if squash_mode {
114 let local_branches =
115 match git_output(&["for-each-ref", "--format=%(refname:short)", "refs/heads"]) {
116 Ok(output) => parse_lines(&output),
117 Err(err) => {
118 eprintln!("{err:#}");
119 return 1;
120 }
121 };
122
123 if local_branches.is_empty() {
124 println!("✅ No local branches found.");
125 return 0;
126 }
127
128 for branch in local_branches {
129 if protected.contains(&branch) {
130 continue;
131 }
132
133 if merged_set.contains(&branch) {
134 candidates.push(branch);
135 continue;
136 }
137
138 let cherry_output = match git_output(&["cherry", "-v", &base_ref, &branch]) {
139 Ok(output) => output,
140 Err(_) => {
141 eprintln!("❌ Failed to compare {branch} against {base_ref}");
142 return 1;
143 }
144 };
145
146 let cherry_text = String::from_utf8_lossy(&cherry_output.stdout);
147 let has_plus = cherry_text.lines().any(|line| line.starts_with('+'));
148 if has_plus {
149 continue;
150 }
151
152 candidates.push(branch);
153 }
154 } else {
155 for branch in merged_branches {
156 if protected.contains(&branch) {
157 continue;
158 }
159 candidates.push(branch);
160 }
161 }
162
163 if candidates.is_empty() {
164 if squash_mode {
165 println!("✅ No deletable branches found.");
166 } else {
167 println!("✅ No deletable merged branches.");
168 }
169 return 0;
170 }
171
172 if squash_mode {
173 println!("🧹 Branches to delete (base: {base_ref}, mode: squash):");
174 } else {
175 println!("🧹 Merged branches to delete (base: {base_ref}):");
176 }
177 for branch in &candidates {
178 println!(" - {branch}");
179 }
180
181 if prompt::confirm_or_abort("❓ Proceed with deleting these branches? [y/N] ").is_err() {
182 return 1;
183 }
184
185 for branch in &candidates {
186 let mut branch_delete_flag = delete_flag;
187 if delete_flag == "-d" && squash_mode && !merged_set.contains(branch) {
188 branch_delete_flag = "-D";
189 }
190 let _ = git_status_success(&["branch", branch_delete_flag, "--", branch]);
191 }
192
193 println!("✅ Deleted merged branches.");
194 0
195}
196
197fn parse_args(args: &[String]) -> Result<CleanupArgs, i32> {
198 let mut base_ref = "HEAD".to_string();
199 let mut squash_mode = false;
200 let mut help = false;
201
202 let mut i = 0usize;
203 while i < args.len() {
204 match args[i].as_str() {
205 "-h" | "--help" => {
206 help = true;
207 }
208 "-s" | "--squash" => {
209 squash_mode = true;
210 }
211 "-b" | "--base" => {
212 let Some(value) = args.get(i + 1) else {
213 return Err(2);
214 };
215 base_ref = value.to_string();
216 i += 1;
217 }
218 _ => {}
219 }
220 i += 1;
221 }
222
223 Ok(CleanupArgs {
224 base_ref,
225 squash_mode,
226 help,
227 })
228}
229
230fn print_help() {
231 println!("Usage: git-delete-merged-branches [-b|--base <ref>] [-s|--squash]");
232 println!(" -b, --base <ref> Base ref used to determine merged branches (default: HEAD)");
233 println!(" -s, --squash Include branches already applied to base (git cherry)");
234}
235
236fn parse_lines(output: &Output) -> Vec<String> {
237 String::from_utf8_lossy(&output.stdout)
238 .lines()
239 .filter(|line| !line.trim().is_empty())
240 .map(|line| line.to_string())
241 .collect()
242}
243
244fn resolve_base_local(base_ref: &str) -> Option<String> {
245 let remote_ref = format!("refs/remotes/{base_ref}");
246 if git_status_success(&["show-ref", "--verify", "--quiet", &remote_ref]) {
247 return Some(
248 base_ref
249 .split_once('/')
250 .map(|(_, tail)| tail.to_string())
251 .unwrap_or_else(|| base_ref.to_string()),
252 );
253 }
254
255 let local_ref = format!("refs/heads/{base_ref}");
256 if git_status_success(&["show-ref", "--verify", "--quiet", &local_ref]) {
257 return Some(base_ref.to_string());
258 }
259
260 None
261}
262
263#[cfg(test)]
264mod tests {
265 use super::{dispatch, parse_args, parse_lines, resolve_base_local};
266 use nils_test_support::{EnvGuard, GlobalStateLock, StubBinDir};
267 use pretty_assertions::assert_eq;
268 use std::process::Command;
269
270 #[test]
271 fn dispatch_unknown_returns_none() {
272 assert_eq!(dispatch("unknown", &[]), None);
273 }
274
275 #[test]
276 fn cleanup_help_exits_success_without_git_runtime() {
277 let args = vec!["--help".to_string()];
278 assert_eq!(dispatch("cleanup", &args), Some(0));
279 assert_eq!(dispatch("delete-merged", &args), Some(0));
280 }
281
282 #[test]
283 fn parse_args_supports_base_and_squash_flags() {
284 let args = vec![
285 "--base".to_string(),
286 "origin/main".to_string(),
287 "--squash".to_string(),
288 "--unknown".to_string(),
289 ];
290 let parsed = parse_args(&args).expect("parsed");
291 assert_eq!(parsed.base_ref, "origin/main");
292 assert!(parsed.squash_mode);
293 assert!(!parsed.help);
294 }
295
296 #[test]
297 fn parse_args_requires_value_for_base_flag() {
298 let args = vec!["--base".to_string()];
299 let err_code = match parse_args(&args) {
300 Ok(_) => panic!("expected usage error"),
301 Err(code) => code,
302 };
303 assert_eq!(err_code, 2);
304 }
305
306 #[test]
307 fn parse_lines_skips_blank_entries() {
308 let output = Command::new("/bin/sh")
309 .arg("-c")
310 .arg("printf 'main\\n\\nfeature/a\\n'")
311 .output()
312 .expect("output");
313 let lines = parse_lines(&output);
314 assert_eq!(lines, vec!["main".to_string(), "feature/a".to_string()]);
315 }
316
317 #[test]
318 fn resolve_base_local_prefers_remote_then_local_then_none() {
319 let lock = GlobalStateLock::new();
320
321 let remote_stubs = StubBinDir::new();
322 remote_stubs.write_exe(
323 "git",
324 r#"#!/bin/bash
325set -euo pipefail
326if [[ "${1:-}" == "show-ref" && "${2:-}" == "--verify" && "${3:-}" == "--quiet" ]]; then
327 if [[ "${4:-}" == "refs/remotes/origin/main" ]]; then
328 exit 0
329 fi
330 exit 1
331fi
332exit 1
333"#,
334 );
335 let remote_guard = EnvGuard::set(&lock, "PATH", &remote_stubs.path_str());
336 assert_eq!(resolve_base_local("origin/main"), Some("main".to_string()));
337 drop(remote_guard);
338
339 let local_stubs = StubBinDir::new();
340 local_stubs.write_exe(
341 "git",
342 r#"#!/bin/bash
343set -euo pipefail
344if [[ "${1:-}" == "show-ref" && "${2:-}" == "--verify" && "${3:-}" == "--quiet" ]]; then
345 if [[ "${4:-}" == "refs/heads/main" ]]; then
346 exit 0
347 fi
348 exit 1
349fi
350exit 1
351"#,
352 );
353 let local_guard = EnvGuard::set(&lock, "PATH", &local_stubs.path_str());
354 assert_eq!(resolve_base_local("main"), Some("main".to_string()));
355 drop(local_guard);
356
357 let none_stubs = StubBinDir::new();
358 none_stubs.write_exe(
359 "git",
360 r#"#!/bin/bash
361set -euo pipefail
362exit 1
363"#,
364 );
365 let _none_guard = EnvGuard::set(&lock, "PATH", &none_stubs.path_str());
366 assert_eq!(resolve_base_local("feature/topic"), None);
367 }
368}