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