Skip to main content

git_cli/
branch.rs

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) = &current_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}