Skip to main content

git_workflow/commands/
cleanup.rs

1//! `gw cleanup` command - Delete merged branch and return to home
2//!
3//! Uses GitHub PR information to make smart decisions about branch deletion.
4
5use crate::error::{GwError, Result};
6use crate::git;
7use crate::github::{self, PrState};
8use crate::output;
9use crate::state::{RepoType, SyncState, WorkingDirState, classify_branch};
10
11/// Execute the `cleanup` command
12pub fn run(branch_name: Option<String>, verbose: bool) -> Result<()> {
13    // Ensure we're in a git repo
14    if !git::is_git_repo() {
15        return Err(GwError::NotAGitRepository);
16    }
17
18    let repo_type = RepoType::detect()?;
19    let home_branch = repo_type.home_branch();
20    let current = git::current_branch()?;
21
22    // Determine which branch to delete
23    let branch_to_delete = match branch_name {
24        Some(name) => name,
25        None => {
26            if current == home_branch {
27                return Err(GwError::AlreadyOnHomeBranch(home_branch.to_string()));
28            }
29            current.clone()
30        }
31    };
32
33    println!();
34    output::info(&format!(
35        "Branch to delete: {}",
36        output::bold(&branch_to_delete)
37    ));
38    output::info(&format!("Home branch: {}", output::bold(home_branch)));
39
40    // Determine if we need to switch branches after cleanup
41    let needs_switch = current == branch_to_delete;
42
43    // Safety: check if branch is protected (compile-time typestate enforced)
44    let branch = classify_branch(&branch_to_delete, &repo_type);
45    let deletable_branch = branch.try_deletable()?;
46
47    // Check if branch exists locally
48    let branch_exists = git::branch_exists(&branch_to_delete);
49    if !branch_exists {
50        output::warn(&format!(
51            "Branch '{}' does not exist locally",
52            branch_to_delete
53        ));
54    }
55
56    // Safety check: uncommitted changes (only needed if switching branches)
57    if needs_switch {
58        let working_dir = WorkingDirState::detect();
59        if !working_dir.is_clean() {
60            output::error(&format!(
61                "You have uncommitted changes ({}).",
62                working_dir.description()
63            ));
64            println!();
65            output::action("git stash -u -m 'WIP before cleanup'");
66            output::action("git status");
67            return Err(GwError::UncommittedChanges);
68        }
69    }
70
71    // Query PR information from GitHub
72    let pr_info = query_pr_info(&branch_to_delete);
73    let force_delete_allowed = should_allow_force_delete(&pr_info, &branch_to_delete);
74
75    // Safety check: unpushed commits (skip if PR is merged)
76    if branch_exists && !force_delete_allowed {
77        check_unpushed_commits(&branch_to_delete)?;
78    }
79
80    // Fetch and prune
81    output::info("Fetching from origin...");
82    git::fetch_prune(verbose)?;
83    output::success("Fetched");
84
85    // Detect default remote branch
86    let default_remote = git::get_default_remote_branch()?;
87    let default_branch = default_remote.strip_prefix("origin/").unwrap_or("main");
88
89    // Switch to home branch first (only if on the branch to delete)
90    if needs_switch {
91        if !git::branch_exists(home_branch) {
92            git::checkout_new_branch(home_branch, &default_remote, verbose)?;
93            output::success(&format!(
94                "Created and switched to {}",
95                output::bold(home_branch)
96            ));
97        } else {
98            git::checkout(home_branch, verbose)?;
99            output::success(&format!("Switched to {}", output::bold(home_branch)));
100        }
101
102        // Sync home branch with default remote (only after switching)
103        output::info(&format!("Syncing with {}...", default_remote));
104        git::pull("origin", default_branch, verbose)?;
105        output::success("Synced");
106    }
107
108    // Delete the local branch
109    if branch_exists {
110        delete_local_branch(
111            deletable_branch,
112            &branch_to_delete,
113            force_delete_allowed,
114            verbose,
115        );
116    }
117
118    // Handle remote branch
119    handle_remote_branch(&branch_to_delete, &pr_info, verbose);
120
121    // Check for stashes
122    let stash_count = git::stash_count();
123    if stash_count > 0 {
124        output::warn(&format!(
125            "You have {} stash(es). Don't forget about them:",
126            stash_count
127        ));
128        output::action("git stash list");
129    }
130
131    if needs_switch {
132        output::ready("Cleanup complete", home_branch);
133        output::hints(&["mise run git:new feature/your-feature  # Create new branch"]);
134    } else {
135        output::success(&format!(
136            "Cleanup complete (stayed on {})",
137            output::bold(&current)
138        ));
139    }
140
141    Ok(())
142}
143
144/// Query PR information from GitHub
145fn query_pr_info(branch: &str) -> Option<github::PrInfo> {
146    if !github::is_gh_available() {
147        output::info("GitHub CLI (gh) not available, skipping PR lookup");
148        return None;
149    }
150
151    output::info("Checking PR status...");
152
153    match github::get_pr_for_branch(branch) {
154        Ok(Some(pr)) => {
155            display_pr_info(&pr);
156            Some(pr)
157        }
158        Ok(None) => {
159            output::info("No PR found for this branch");
160            None
161        }
162        Err(e) => {
163            output::warn(&format!("Could not fetch PR info: {}", e));
164            None
165        }
166    }
167}
168
169/// Display PR information
170fn display_pr_info(pr: &github::PrInfo) {
171    let state_display = match &pr.state {
172        PrState::Open => "OPEN".to_string(),
173        PrState::Merged { method, .. } => format!("MERGED ({})", method),
174        PrState::Closed => "CLOSED".to_string(),
175    };
176
177    output::success(&format!(
178        "PR #{}: {} [{}]",
179        pr.number, pr.title, state_display
180    ));
181}
182
183/// Determine if force delete should be allowed based on PR state
184fn should_allow_force_delete(pr_info: &Option<github::PrInfo>, branch: &str) -> bool {
185    match pr_info {
186        Some(pr) => match &pr.state {
187            PrState::Merged { method, .. } => {
188                output::info(&format!("PR was {} merged, safe to force delete", method));
189                true
190            }
191            PrState::Open => {
192                output::warn("PR is still OPEN, be careful!");
193                false
194            }
195            PrState::Closed => {
196                output::warn("PR was closed without merging");
197                false
198            }
199        },
200        None => {
201            // No PR info - check if remote branch exists
202            if git::remote_branch_exists(branch) {
203                output::info("No PR found but remote branch exists");
204                false
205            } else {
206                // Branch was never pushed or already deleted from remote
207                true
208            }
209        }
210    }
211}
212
213/// Check for unpushed commits
214fn check_unpushed_commits(branch: &str) -> Result<()> {
215    if git::has_remote_tracking(branch) {
216        let sync_state = SyncState::detect(branch)?;
217        if sync_state.has_unpushed() {
218            let count = sync_state.unpushed_count();
219            output::error(&format!(
220                "Branch '{}' has {} unpushed commit(s)!",
221                branch, count
222            ));
223            println!();
224
225            // Show unpushed commits
226            if let Ok(commits) =
227                git::log_commits(&format!("{}@{{upstream}}", branch), branch, false)
228            {
229                println!("Unpushed commits:");
230                for commit in commits.iter().take(5) {
231                    println!("  {commit}");
232                }
233                println!();
234            }
235
236            output::action(&format!("git push origin {}  # Push first", branch));
237            output::action(&format!(
238                "git branch -D {}    # Or force delete (lose commits)",
239                branch
240            ));
241            return Err(GwError::UnpushedCommits(branch.to_string(), count));
242        }
243    } else {
244        // No remote tracking
245        if git::remote_branch_exists(branch) {
246            output::info("Branch has no tracking but remote exists (PR probably merged)");
247        } else {
248            output::warn(&format!("Branch '{}' was never pushed to remote", branch));
249            output::warn("Commits on this branch will be lost if deleted");
250        }
251    }
252    Ok(())
253}
254
255/// Delete local branch, using force delete if allowed
256fn delete_local_branch(
257    deletable_branch: crate::state::Branch<crate::state::Deletable>,
258    branch_name: &str,
259    force_allowed: bool,
260    verbose: bool,
261) {
262    match deletable_branch.delete(verbose) {
263        Ok(()) => {
264            output::success(&format!(
265                "Deleted local branch {}",
266                output::bold(branch_name)
267            ));
268        }
269        Err(_) => {
270            if force_allowed {
271                // PR was merged, safe to force delete
272                output::info(
273                    "Branch not fully merged locally, but PR was merged. Force deleting...",
274                );
275                if let Err(e) = git::force_delete_branch(branch_name, verbose) {
276                    output::warn(&format!("Force delete failed: {}", e));
277                } else {
278                    output::success(&format!(
279                        "Force deleted local branch {}",
280                        output::bold(branch_name)
281                    ));
282                }
283            } else {
284                output::warn("Branch not fully merged. Use -D to force delete:");
285                output::action(&format!("git branch -D {}", branch_name));
286            }
287        }
288    }
289}
290
291/// Handle remote branch deletion
292fn handle_remote_branch(branch: &str, pr_info: &Option<github::PrInfo>, verbose: bool) {
293    let remote_exists = git::remote_branch_exists(branch);
294
295    if !remote_exists {
296        // Remote branch already deleted (GitHub auto-delete after merge)
297        if let Some(pr) = pr_info {
298            if matches!(pr.state, PrState::Merged { .. }) {
299                output::success("Remote branch already deleted by GitHub");
300            }
301        }
302        return;
303    }
304
305    // Remote branch still exists
306    match pr_info {
307        Some(pr) if matches!(pr.state, PrState::Merged { .. }) => {
308            // PR merged but remote branch exists - delete it
309            output::info("PR merged, deleting remote branch...");
310            match github::delete_remote_branch(branch) {
311                Ok(()) => {
312                    output::success(&format!(
313                        "Deleted remote branch origin/{}",
314                        output::bold(branch)
315                    ));
316                }
317                Err(e) => {
318                    output::warn(&format!("Failed to delete remote branch: {}", e));
319                    output::action(&format!("git push origin --delete {}", branch));
320                }
321            }
322        }
323        Some(pr) if matches!(pr.state, PrState::Open) => {
324            output::warn(&format!(
325                "Remote branch exists and PR #{} is still open",
326                pr.number
327            ));
328            output::action(&format!("gh pr view {}", pr.number));
329        }
330        _ => {
331            output::warn(&format!("Remote branch still exists: origin/{}", branch));
332            if verbose {
333                output::action(&format!("git push origin --delete {}", branch));
334            }
335        }
336    }
337}