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