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    // Pool worktree auto-release: if we're inside a pool worktree,
127    // clean untracked files, remove the acquire marker, and run setup hook
128    if let Some(pool_name) = super::worktree_pool::detect_pool_worktree() {
129        super::worktree_pool::pool_release_after_cleanup(&pool_name, verbose)?;
130    }
131
132    output::ready("Cleanup complete", home_branch);
133    output::hints(&["mise run git:new feature/your-feature  # Create new branch"]);
134
135    Ok(())
136}
137
138/// Query PR information from GitHub
139fn query_pr_info(branch: &str) -> Option<github::PrInfo> {
140    if !github::is_gh_available() {
141        output::info("GitHub CLI (gh) not available, skipping PR lookup");
142        return None;
143    }
144
145    output::info("Checking PR status...");
146
147    match github::get_pr_for_branch(branch) {
148        Ok(Some(pr)) => {
149            display_pr_info(&pr);
150            Some(pr)
151        }
152        Ok(None) => {
153            output::info("No PR found for this branch");
154            None
155        }
156        Err(e) => {
157            output::warn(&format!("Could not fetch PR info: {}", e));
158            None
159        }
160    }
161}
162
163/// Display PR information
164fn display_pr_info(pr: &github::PrInfo) {
165    let state_display = match &pr.state {
166        PrState::Open => "OPEN".to_string(),
167        PrState::Merged { method, .. } => format!("MERGED ({})", method),
168        PrState::Closed => "CLOSED".to_string(),
169    };
170
171    output::success(&format!(
172        "PR #{}: {} [{}]",
173        pr.number, pr.title, state_display
174    ));
175}
176
177/// Determine if force delete should be allowed based on PR state
178fn should_allow_force_delete(pr_info: &Option<github::PrInfo>, branch: &str) -> bool {
179    match pr_info {
180        Some(pr) => match &pr.state {
181            PrState::Merged { method, .. } => {
182                output::info(&format!("PR was {} merged, safe to force delete", method));
183                true
184            }
185            PrState::Open => {
186                output::warn("PR is still OPEN, be careful!");
187                false
188            }
189            PrState::Closed => {
190                output::warn("PR was closed without merging");
191                false
192            }
193        },
194        None => {
195            // No PR info - check if remote branch exists
196            if git::remote_branch_exists(branch) {
197                output::info("No PR found but remote branch exists");
198                false
199            } else {
200                // Branch was never pushed or already deleted from remote
201                true
202            }
203        }
204    }
205}
206
207/// Check for unpushed commits
208fn check_unpushed_commits(branch: &str) -> Result<()> {
209    if git::has_remote_tracking(branch) {
210        let sync_state = SyncState::detect(branch)?;
211        if sync_state.has_unpushed() {
212            let count = sync_state.unpushed_count();
213            output::error(&format!(
214                "Branch '{}' has {} unpushed commit(s)!",
215                branch, count
216            ));
217            println!();
218
219            // Show unpushed commits
220            if let Ok(commits) =
221                git::log_commits(&format!("{}@{{upstream}}", branch), branch, false)
222            {
223                println!("Unpushed commits:");
224                for commit in commits.iter().take(5) {
225                    println!("  {commit}");
226                }
227                println!();
228            }
229
230            output::action(&format!("git push origin {}  # Push first", branch));
231            output::action(&format!(
232                "git branch -D {}    # Or force delete (lose commits)",
233                branch
234            ));
235            return Err(GwError::UnpushedCommits(branch.to_string(), count));
236        }
237    } else {
238        // No remote tracking
239        if git::remote_branch_exists(branch) {
240            output::info("Branch has no tracking but remote exists (PR probably merged)");
241        } else {
242            output::warn(&format!("Branch '{}' was never pushed to remote", branch));
243            output::warn("Commits on this branch will be lost if deleted");
244        }
245    }
246    Ok(())
247}
248
249/// Delete local branch, using force delete if allowed
250fn delete_local_branch(
251    deletable_branch: crate::state::Branch<crate::state::Deletable>,
252    branch_name: &str,
253    force_allowed: bool,
254    verbose: bool,
255) {
256    match deletable_branch.delete(verbose) {
257        Ok(()) => {
258            output::success(&format!(
259                "Deleted local branch {}",
260                output::bold(branch_name)
261            ));
262        }
263        Err(_) => {
264            if force_allowed {
265                // PR was merged, safe to force delete
266                output::info(
267                    "Branch not fully merged locally, but PR was merged. Force deleting...",
268                );
269                if let Err(e) = git::force_delete_branch(branch_name, verbose) {
270                    output::warn(&format!("Force delete failed: {}", e));
271                } else {
272                    output::success(&format!(
273                        "Force deleted local branch {}",
274                        output::bold(branch_name)
275                    ));
276                }
277            } else {
278                output::warn("Branch not fully merged. Use -D to force delete:");
279                output::action(&format!("git branch -D {}", branch_name));
280            }
281        }
282    }
283}
284
285/// Handle remote branch deletion
286fn handle_remote_branch(branch: &str, pr_info: &Option<github::PrInfo>, verbose: bool) {
287    let remote_exists = git::remote_branch_exists(branch);
288
289    if !remote_exists {
290        // Remote branch already deleted (GitHub auto-delete after merge)
291        if let Some(pr) = pr_info {
292            if matches!(pr.state, PrState::Merged { .. }) {
293                output::success("Remote branch already deleted by GitHub");
294            }
295        }
296        return;
297    }
298
299    // Remote branch still exists
300    match pr_info {
301        Some(pr) if matches!(pr.state, PrState::Merged { .. }) => {
302            // PR merged but remote branch exists - delete it
303            output::info("PR merged, deleting remote branch...");
304            match github::delete_remote_branch(branch) {
305                Ok(()) => {
306                    output::success(&format!(
307                        "Deleted remote branch origin/{}",
308                        output::bold(branch)
309                    ));
310                }
311                Err(e) => {
312                    output::warn(&format!("Failed to delete remote branch: {}", e));
313                    output::action(&format!("git push origin --delete {}", branch));
314                }
315            }
316        }
317        Some(pr) if matches!(pr.state, PrState::Open) => {
318            output::warn(&format!(
319                "Remote branch exists and PR #{} is still open",
320                pr.number
321            ));
322            output::action(&format!("gh pr view {}", pr.number));
323        }
324        _ => {
325            output::warn(&format!("Remote branch still exists: origin/{}", branch));
326            if verbose {
327                output::action(&format!("git push origin --delete {}", branch));
328            }
329        }
330    }
331}