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::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}