1use crate::commit_shared::{git_output, git_status_success, git_stdout_trimmed};
2use crate::prompt;
3use std::collections::{HashMap, 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 remove_worktrees: bool,
17 help: bool,
18}
19
20fn run_cleanup(args: &[String]) -> i32 {
21 let parsed = match parse_args(args) {
22 Ok(value) => value,
23 Err(code) => return code,
24 };
25
26 if parsed.help {
27 print_help();
28 return 0;
29 }
30
31 if !git_status_success(&["rev-parse", "--is-inside-work-tree"]) {
32 eprintln!("❌ Not in a git repository");
33 return 1;
34 }
35
36 let base_ref = parsed.base_ref;
37 let squash_mode = parsed.squash_mode;
38 let remove_worktrees = parsed.remove_worktrees;
39
40 if !git_status_success(&["rev-parse", "--verify", "--quiet", &base_ref]) {
41 eprintln!("❌ Invalid base ref: {base_ref}");
42 return 1;
43 }
44
45 let base_commit = match git_stdout_trimmed(&["rev-parse", &format!("{base_ref}^{{commit}}")]) {
46 Ok(value) => value,
47 Err(_) => {
48 eprintln!("❌ Unable to resolve base commit: {base_ref}");
49 return 1;
50 }
51 };
52
53 let head_commit = match git_stdout_trimmed(&["rev-parse", "HEAD"]) {
54 Ok(value) => value,
55 Err(_) => {
56 eprintln!("❌ Unable to resolve HEAD commit");
57 return 1;
58 }
59 };
60
61 let delete_flag = if base_commit != head_commit {
62 "-D"
63 } else {
64 "-d"
65 };
66
67 let current_branch = match git_stdout_trimmed(&["rev-parse", "--abbrev-ref", "HEAD"]) {
68 Ok(value) => value,
69 Err(_) => {
70 eprintln!("❌ Unable to resolve current branch");
71 return 1;
72 }
73 };
74
75 let mut protected: HashSet<String> = ["main", "master", "develop", "trunk"]
76 .iter()
77 .map(|name| (*name).to_string())
78 .collect();
79
80 if current_branch != "HEAD" {
81 protected.insert(current_branch.clone());
82 }
83 protected.insert(base_ref.clone());
84
85 if let Some(base_local) = resolve_base_local(&base_ref) {
86 protected.insert(base_local);
87 }
88
89 let merged_branches = match git_output(&[
90 "for-each-ref",
91 "--merged",
92 &base_ref,
93 "--format=%(refname:short)",
94 "refs/heads",
95 ]) {
96 Ok(output) => parse_lines(&output),
97 Err(err) => {
98 eprintln!("{err:#}");
99 return 1;
100 }
101 };
102
103 let mut merged_set: HashSet<String> = HashSet::new();
104 for branch in &merged_branches {
105 merged_set.insert(branch.clone());
106 }
107
108 let linked_worktrees = match linked_worktrees_by_branch() {
109 Ok(value) => value,
110 Err(err) => {
111 eprintln!("{err:#}");
112 return 1;
113 }
114 };
115
116 if !squash_mode && merged_branches.is_empty() {
117 println!("✅ No merged local branches found.");
118 return 0;
119 }
120
121 let mut candidates: Vec<String> = Vec::new();
122
123 if squash_mode {
124 let local_branches =
125 match git_output(&["for-each-ref", "--format=%(refname:short)", "refs/heads"]) {
126 Ok(output) => parse_lines(&output),
127 Err(err) => {
128 eprintln!("{err:#}");
129 return 1;
130 }
131 };
132
133 if local_branches.is_empty() {
134 println!("✅ No local branches found.");
135 return 0;
136 }
137
138 for branch in local_branches {
139 if protected.contains(&branch) {
140 continue;
141 }
142
143 if merged_set.contains(&branch) {
144 candidates.push(branch);
145 continue;
146 }
147
148 let cherry_output = match git_output(&["cherry", "-v", &base_ref, &branch]) {
149 Ok(output) => output,
150 Err(_) => {
151 eprintln!("❌ Failed to compare {branch} against {base_ref}");
152 return 1;
153 }
154 };
155
156 let cherry_text = String::from_utf8_lossy(&cherry_output.stdout);
157 let has_plus = cherry_text.lines().any(|line| line.starts_with('+'));
158 if has_plus {
159 continue;
160 }
161
162 candidates.push(branch);
163 }
164 } else {
165 for branch in merged_branches {
166 if protected.contains(&branch) {
167 continue;
168 }
169 candidates.push(branch);
170 }
171 }
172
173 if candidates.is_empty() {
174 if squash_mode {
175 println!("✅ No deletable branches found.");
176 } else {
177 println!("✅ No deletable merged branches.");
178 }
179 return 0;
180 }
181
182 if squash_mode {
183 println!("🧹 Branches to delete (base: {base_ref}, mode: squash):");
184 } else {
185 println!("🧹 Merged branches to delete (base: {base_ref}):");
186 }
187 for branch in &candidates {
188 println!(" - {branch}");
189 }
190
191 if remove_worktrees {
192 let removable_worktrees: Vec<_> = candidates
193 .iter()
194 .filter_map(|branch| {
195 linked_worktrees
196 .get(branch)
197 .map(|worktree_path| (branch, worktree_path))
198 })
199 .collect();
200
201 if !removable_worktrees.is_empty() {
202 println!("⚠️ Linked worktrees to remove (--remove-worktrees):");
203 for (branch, worktree_path) in removable_worktrees {
204 println!(" - {branch}: {worktree_path}");
205 }
206 }
207 }
208
209 if prompt::confirm_or_abort("❓ Proceed with deleting these branches? [y/N] ").is_err() {
210 return 1;
211 }
212
213 let mut deleted_count = 0usize;
214 let mut removed_worktrees_count = 0usize;
215 let mut failed_deletions: Vec<(String, String)> = Vec::new();
216
217 for branch in &candidates {
218 let mut branch_delete_flag = delete_flag;
219 if delete_flag == "-d" && squash_mode && !merged_set.contains(branch) {
220 branch_delete_flag = "-D";
221 }
222
223 if remove_worktrees && let Some(worktree_path) = linked_worktrees.get(branch) {
224 match git_output(&["worktree", "remove", "--force", worktree_path]) {
225 Ok(_) => {
226 removed_worktrees_count += 1;
227 }
228 Err(err) => {
229 failed_deletions.push((
230 branch.clone(),
231 format!(
232 "failed to remove linked worktree {worktree_path}: {}",
233 summarize_git_error(&err.to_string())
234 ),
235 ));
236 continue;
237 }
238 }
239 }
240
241 match git_output(&["branch", branch_delete_flag, "--", branch]) {
242 Ok(_) => {
243 deleted_count += 1;
244 }
245 Err(err) => {
246 failed_deletions.push((branch.clone(), summarize_git_error(&err.to_string())));
247 }
248 }
249 }
250
251 if removed_worktrees_count > 0 {
252 println!("✅ Removed {removed_worktrees_count} linked worktree(s).");
253 }
254
255 if !failed_deletions.is_empty() {
256 if deleted_count > 0 {
257 println!("✅ Deleted {deleted_count} branch(es).");
258 }
259
260 eprintln!(
261 "⚠️ Failed to delete {} branch(es):",
262 failed_deletions.len()
263 );
264 for (branch, reason) in &failed_deletions {
265 eprintln!(" - {branch}: {reason}");
266 }
267 return 1;
268 }
269
270 println!("✅ Deleted {deleted_count} branch(es).");
271 0
272}
273
274fn parse_args(args: &[String]) -> Result<CleanupArgs, i32> {
275 let mut base_ref = "HEAD".to_string();
276 let mut squash_mode = false;
277 let mut remove_worktrees = false;
278 let mut help = false;
279
280 let mut i = 0usize;
281 while i < args.len() {
282 match args[i].as_str() {
283 "-h" | "--help" => {
284 help = true;
285 }
286 "-s" | "--squash" => {
287 squash_mode = true;
288 }
289 "-w" | "--remove-worktrees" => {
290 remove_worktrees = true;
291 }
292 "-b" | "--base" => {
293 let Some(value) = args.get(i + 1) else {
294 return Err(2);
295 };
296 base_ref = value.to_string();
297 i += 1;
298 }
299 _ => {}
300 }
301 i += 1;
302 }
303
304 Ok(CleanupArgs {
305 base_ref,
306 squash_mode,
307 remove_worktrees,
308 help,
309 })
310}
311
312fn print_help() {
313 println!(
314 "Usage: git-delete-merged-branches [-b|--base <ref>] [-s|--squash] [-w|--remove-worktrees]"
315 );
316 println!(" -b, --base <ref> Base ref used to determine merged branches (default: HEAD)");
317 println!(" -s, --squash Include branches already applied to base (git cherry)");
318 println!(" -w, --remove-worktrees Force-remove linked worktrees for candidate branches");
319}
320
321fn parse_lines(output: &Output) -> Vec<String> {
322 String::from_utf8_lossy(&output.stdout)
323 .lines()
324 .filter(|line| !line.trim().is_empty())
325 .map(|line| line.to_string())
326 .collect()
327}
328
329fn summarize_git_error(message: &str) -> String {
330 let trimmed = message.trim();
331 let summary = trimmed
332 .rsplit_once(" failed: ")
333 .map(|(_, suffix)| suffix.trim())
334 .unwrap_or(trimmed);
335 summary.replace('\n', " ")
336}
337
338fn linked_worktrees_by_branch() -> anyhow::Result<HashMap<String, String>> {
339 let output = git_output(&["worktree", "list", "--porcelain"])?;
340 let mut branch_worktrees: HashMap<String, String> = HashMap::new();
341 let mut current_worktree_path: Option<String> = None;
342
343 for line in String::from_utf8_lossy(&output.stdout).lines() {
344 if line.trim().is_empty() {
345 current_worktree_path = None;
346 continue;
347 }
348
349 if let Some(path) = line.strip_prefix("worktree ") {
350 current_worktree_path = Some(path.to_string());
351 continue;
352 }
353
354 if let Some(branch_ref) = line.strip_prefix("branch refs/heads/")
355 && let Some(worktree_path) = ¤t_worktree_path
356 {
357 branch_worktrees.insert(branch_ref.to_string(), worktree_path.clone());
358 }
359 }
360
361 Ok(branch_worktrees)
362}
363
364fn resolve_base_local(base_ref: &str) -> Option<String> {
365 let remote_ref = format!("refs/remotes/{base_ref}");
366 if git_status_success(&["show-ref", "--verify", "--quiet", &remote_ref]) {
367 return Some(
368 base_ref
369 .split_once('/')
370 .map(|(_, tail)| tail.to_string())
371 .unwrap_or_else(|| base_ref.to_string()),
372 );
373 }
374
375 let local_ref = format!("refs/heads/{base_ref}");
376 if git_status_success(&["show-ref", "--verify", "--quiet", &local_ref]) {
377 return Some(base_ref.to_string());
378 }
379
380 None
381}
382
383#[cfg(test)]
384mod tests {
385 use super::{
386 dispatch, linked_worktrees_by_branch, parse_args, parse_lines, resolve_base_local,
387 summarize_git_error,
388 };
389 use nils_test_support::{EnvGuard, GlobalStateLock, StubBinDir};
390 use pretty_assertions::assert_eq;
391 use std::process::Command;
392
393 #[test]
394 fn dispatch_unknown_returns_none() {
395 assert_eq!(dispatch("unknown", &[]), None);
396 }
397
398 #[test]
399 fn cleanup_help_exits_success_without_git_runtime() {
400 let args = vec!["--help".to_string()];
401 assert_eq!(dispatch("cleanup", &args), Some(0));
402 assert_eq!(dispatch("delete-merged", &args), Some(0));
403 }
404
405 #[test]
406 fn parse_args_supports_base_and_squash_flags() {
407 let args = vec![
408 "--base".to_string(),
409 "origin/main".to_string(),
410 "--squash".to_string(),
411 "--remove-worktrees".to_string(),
412 "--unknown".to_string(),
413 ];
414 let parsed = parse_args(&args).expect("parsed");
415 assert_eq!(parsed.base_ref, "origin/main");
416 assert!(parsed.squash_mode);
417 assert!(parsed.remove_worktrees);
418 assert!(!parsed.help);
419 }
420
421 #[test]
422 fn parse_args_requires_value_for_base_flag() {
423 let args = vec!["--base".to_string()];
424 let err_code = match parse_args(&args) {
425 Ok(_) => panic!("expected usage error"),
426 Err(code) => code,
427 };
428 assert_eq!(err_code, 2);
429 }
430
431 #[test]
432 fn parse_lines_skips_blank_entries() {
433 let output = Command::new("/bin/sh")
434 .arg("-c")
435 .arg("printf 'main\\n\\nfeature/a\\n'")
436 .output()
437 .expect("output");
438 let lines = parse_lines(&output);
439 assert_eq!(lines, vec!["main".to_string(), "feature/a".to_string()]);
440 }
441
442 #[test]
443 fn summarize_git_error_strips_prefix_and_normalizes_lines() {
444 let message =
445 "git [\"branch\", \"-d\"] failed: error: cannot delete branch\nhint: checked out";
446 let summary = summarize_git_error(message);
447 assert_eq!(summary, "error: cannot delete branch hint: checked out");
448 }
449
450 #[test]
451 fn linked_worktrees_by_branch_parses_porcelain_output() {
452 let lock = GlobalStateLock::new();
453 let stubs = StubBinDir::new();
454 stubs.write_exe(
455 "git",
456 r#"#!/bin/bash
457set -euo pipefail
458if [[ "${1:-}" == "worktree" && "${2:-}" == "list" && "${3:-}" == "--porcelain" ]]; then
459 printf 'worktree /repo\n'
460 printf 'HEAD 1111111111111111111111111111111111111111\n'
461 printf 'branch refs/heads/main\n'
462 printf '\n'
463 printf 'worktree /repo/wt/topic\n'
464 printf 'HEAD 2222222222222222222222222222222222222222\n'
465 printf 'branch refs/heads/feature/topic\n'
466 exit 0
467fi
468exit 1
469"#,
470 );
471
472 let _guard = EnvGuard::set(&lock, "PATH", &stubs.path_str());
473 let mapping = linked_worktrees_by_branch().expect("parse linked worktrees");
474 assert_eq!(
475 mapping.get("feature/topic"),
476 Some(&"/repo/wt/topic".to_string())
477 );
478 assert_eq!(mapping.get("main"), Some(&"/repo".to_string()));
479 }
480
481 #[test]
482 fn resolve_base_local_prefers_remote_then_local_then_none() {
483 let lock = GlobalStateLock::new();
484
485 let remote_stubs = StubBinDir::new();
486 remote_stubs.write_exe(
487 "git",
488 r#"#!/bin/bash
489set -euo pipefail
490if [[ "${1:-}" == "show-ref" && "${2:-}" == "--verify" && "${3:-}" == "--quiet" ]]; then
491 if [[ "${4:-}" == "refs/remotes/origin/main" ]]; then
492 exit 0
493 fi
494 exit 1
495fi
496exit 1
497"#,
498 );
499 let remote_guard = EnvGuard::set(&lock, "PATH", &remote_stubs.path_str());
500 assert_eq!(resolve_base_local("origin/main"), Some("main".to_string()));
501 drop(remote_guard);
502
503 let local_stubs = StubBinDir::new();
504 local_stubs.write_exe(
505 "git",
506 r#"#!/bin/bash
507set -euo pipefail
508if [[ "${1:-}" == "show-ref" && "${2:-}" == "--verify" && "${3:-}" == "--quiet" ]]; then
509 if [[ "${4:-}" == "refs/heads/main" ]]; then
510 exit 0
511 fi
512 exit 1
513fi
514exit 1
515"#,
516 );
517 let local_guard = EnvGuard::set(&lock, "PATH", &local_stubs.path_str());
518 assert_eq!(resolve_base_local("main"), Some("main".to_string()));
519 drop(local_guard);
520
521 let none_stubs = StubBinDir::new();
522 none_stubs.write_exe(
523 "git",
524 r#"#!/bin/bash
525set -euo pipefail
526exit 1
527"#,
528 );
529 let _none_guard = EnvGuard::set(&lock, "PATH", &none_stubs.path_str());
530 assert_eq!(resolve_base_local("feature/topic"), None);
531 }
532}