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 super::helpers;
6use crate::error::{GwError, Result};
7use crate::git;
8use crate::github::{self, PrState};
9use crate::output;
10use crate::state::{RepoType, SyncState, WorkingDirState, classify_branch};
11
12/// Execute the `cleanup` command
13pub fn run(branch_name: Option<String>, verbose: bool) -> Result<()> {
14    // Ensure we're in a git repo
15    if !git::is_git_repo() {
16        return Err(GwError::NotAGitRepository);
17    }
18
19    let repo_type = RepoType::detect()?;
20    let home_branch = repo_type.home_branch();
21    let current = git::current_branch()?;
22
23    // Determine which branch to delete
24    let branch_to_delete = match branch_name {
25        Some(name) => name,
26        None => {
27            if current == home_branch {
28                return Err(GwError::AlreadyOnHomeBranch(home_branch.to_string()));
29            }
30            current.clone()
31        }
32    };
33
34    println!();
35    output::info(&format!(
36        "Branch to delete: {}",
37        output::bold(&branch_to_delete)
38    ));
39    output::info(&format!("Home branch: {}", output::bold(home_branch)));
40
41    // Determine if we need to switch branches after cleanup
42    let needs_switch = current == branch_to_delete;
43
44    // Safety: check if branch is protected (compile-time typestate enforced)
45    let branch = classify_branch(&branch_to_delete, &repo_type);
46    let deletable_branch = branch.try_deletable()?;
47
48    // Check if branch exists locally
49    let branch_exists = git::branch_exists(&branch_to_delete);
50    if !branch_exists {
51        output::warn(&format!(
52            "Branch '{}' does not exist locally",
53            branch_to_delete
54        ));
55    }
56
57    // Safety check: uncommitted changes (only needed if switching branches)
58    if needs_switch {
59        let working_dir = WorkingDirState::detect();
60        if !working_dir.is_clean() {
61            output::error(&format!(
62                "You have uncommitted changes ({}).",
63                working_dir.description()
64            ));
65            println!();
66            output::action("git add <files> && git commit -m \"...\"  # commit first");
67            output::action("gw pause                                  # or park the work as WIP");
68            output::action("gw abandon                                # or discard it");
69            return Err(GwError::UncommittedChanges);
70        }
71    }
72
73    // Query PR information from GitHub
74    let pr_info = query_pr_info(&branch_to_delete);
75    let force_delete_allowed = should_allow_force_delete(&pr_info);
76
77    // Safety check: unpushed commits (skip if PR is merged)
78    if branch_exists && !force_delete_allowed {
79        check_unpushed_commits(&branch_to_delete)?;
80    }
81
82    // Fetch and prune
83    output::info("Fetching from origin...");
84    git::fetch_prune(verbose)?;
85    output::success("Fetched");
86
87    // Detect default remote branch
88    let default_remote = git::get_default_remote_branch()?;
89    let default_branch = default_remote.strip_prefix("origin/").unwrap_or("main");
90
91    // Switch to home branch first (only if on the branch to delete)
92    if needs_switch {
93        if !git::branch_exists(home_branch) {
94            git::checkout_new_branch(home_branch, &default_remote, verbose)?;
95            output::success(&format!(
96                "Created and switched to {}",
97                output::bold(home_branch)
98            ));
99        } else {
100            git::checkout(home_branch, verbose)?;
101            output::success(&format!("Switched to {}", output::bold(home_branch)));
102        }
103
104        // Sync home branch with default remote (only after switching).
105        // Fast-forward only: if home has diverged from origin we stop rather
106        // than silently creating a merge commit.
107        helpers::pull_with_output(&default_remote, default_branch, verbose)?;
108    }
109
110    // Delete the local branch
111    if branch_exists {
112        delete_local_branch(
113            deletable_branch,
114            &branch_to_delete,
115            force_delete_allowed,
116            verbose,
117        );
118    }
119
120    // Handle remote branch
121    handle_remote_branch(&branch_to_delete, &pr_info, verbose);
122
123    // Check for stashes
124    let stash_count = git::stash_count();
125    if stash_count > 0 {
126        output::warn(&format!(
127            "You have {} stash(es). Don't forget about them:",
128            stash_count
129        ));
130        output::action("git stash list");
131    }
132
133    if needs_switch {
134        output::ready("Cleanup complete", home_branch);
135        output::hints(&["gw new feature/your-feature  # Create new branch"]);
136    } else {
137        output::success(&format!(
138            "Cleanup complete (stayed on {})",
139            output::bold(&current)
140        ));
141    }
142
143    Ok(())
144}
145
146/// Query PR information from GitHub
147fn query_pr_info(branch: &str) -> Option<github::PrInfo> {
148    if !github::is_gh_available() {
149        output::info("GitHub CLI (gh) not available, skipping PR lookup");
150        return None;
151    }
152
153    output::info("Checking PR status...");
154
155    match github::get_pr_for_branch(branch) {
156        Ok(Some(pr)) => {
157            display_pr_info(&pr);
158            Some(pr)
159        }
160        Ok(None) => {
161            output::info("No PR found for this branch");
162            None
163        }
164        Err(e) => {
165            output::warn(&format!("Could not fetch PR info: {}", e));
166            None
167        }
168    }
169}
170
171/// Display PR information
172fn display_pr_info(pr: &github::PrInfo) {
173    let state_display = match &pr.state {
174        PrState::Open => "OPEN".to_string(),
175        PrState::Merged { method, .. } => format!("MERGED ({})", method),
176        PrState::Closed => "CLOSED".to_string(),
177    };
178
179    output::success(&format!(
180        "PR #{}: {} [{}]",
181        pr.number, pr.title, state_display
182    ));
183}
184
185/// Determine if force delete should be allowed based on PR state.
186///
187/// `cleanup` is the "tidy up a merged branch" command, not "throw away local
188/// work". So force delete is allowed ONLY when we can positively confirm the PR
189/// was merged. Every other situation — open PR, closed-without-merge, no PR
190/// found, gh unavailable, or a network failure querying GitHub — is treated as
191/// "could not confirm it's safe", and we refuse to force-delete. The caller
192/// still attempts a plain `git branch -d`; if that fails the user is told to
193/// discard the work explicitly (`git branch -D` or `gw abandon`).
194fn should_allow_force_delete(pr_info: &Option<github::PrInfo>) -> bool {
195    match pr_info {
196        Some(pr) => match &pr.state {
197            PrState::Merged { method, .. } => {
198                output::info(&format!("PR was {} merged, safe to force delete", method));
199                true
200            }
201            PrState::Open => {
202                output::warn("PR is still OPEN, be careful!");
203                false
204            }
205            PrState::Closed => {
206                output::warn("PR was closed without merging");
207                false
208            }
209        },
210        None => {
211            // No merged PR could be confirmed (no PR, gh unavailable, or lookup
212            // failed). Not safe to discard local commits automatically.
213            output::warn("No merged PR confirmed; will not force-delete unmerged commits");
214            false
215        }
216    }
217}
218
219/// Check for unpushed commits
220fn check_unpushed_commits(branch: &str) -> Result<()> {
221    if git::has_remote_tracking(branch) {
222        let sync_state = SyncState::detect(branch)?;
223        if sync_state.has_unpushed() {
224            let count = sync_state.unpushed_count();
225            output::error(&format!(
226                "Branch '{}' has {} unpushed commit(s)!",
227                branch, count
228            ));
229            println!();
230
231            // Show unpushed commits
232            if let Ok(commits) =
233                git::log_commits(&format!("{}@{{upstream}}", branch), branch, false)
234            {
235                println!("Unpushed commits:");
236                for commit in commits.iter().take(5) {
237                    println!("  {commit}");
238                }
239                println!();
240            }
241
242            output::action(&format!("git push origin {}  # Push first", branch));
243            output::action(&format!(
244                "git branch -D {}    # Or force delete (lose commits)",
245                branch
246            ));
247            return Err(GwError::UnpushedCommits(branch.to_string(), count));
248        }
249    } else {
250        // No remote tracking. Be conservative: only claim the work is safe
251        // (remote copy exists) when we can positively confirm it.
252        match git::remote_branch_exists(branch) {
253            Ok(true) => {
254                output::info("Branch has no tracking but remote exists (PR probably merged)")
255            }
256            Ok(false) => {
257                output::warn(&format!("Branch '{}' was never pushed to remote", branch));
258                output::warn("Commits on this branch will be lost if deleted");
259            }
260            Err(e) => {
261                output::warn(&format!("Could not verify remote for '{}': {}", branch, e));
262                output::warn("Commits on this branch may be lost if deleted");
263            }
264        }
265    }
266    Ok(())
267}
268
269/// Delete local branch, using force delete if allowed
270fn delete_local_branch(
271    deletable_branch: crate::state::Branch<crate::state::Deletable>,
272    branch_name: &str,
273    force_allowed: bool,
274    verbose: bool,
275) {
276    match deletable_branch.delete(verbose) {
277        Ok(()) => {
278            output::success(&format!(
279                "Deleted local branch {}",
280                output::bold(branch_name)
281            ));
282        }
283        Err(_) => {
284            if force_allowed {
285                // PR was merged, safe to force delete
286                output::info(
287                    "Branch not fully merged locally, but PR was merged. Force deleting...",
288                );
289                if let Err(e) = git::force_delete_branch(branch_name, verbose) {
290                    output::warn(&format!("Force delete failed: {}", e));
291                } else {
292                    output::success(&format!(
293                        "Force deleted local branch {}",
294                        output::bold(branch_name)
295                    ));
296                }
297            } else {
298                output::warn("Branch not fully merged. Use -D to force delete:");
299                output::action(&format!("git branch -D {}", branch_name));
300            }
301        }
302    }
303}
304
305/// Whether deleting `branch`'s remote would orphan open child PRs.
306///
307/// GitHub closes a PR when its base branch is deleted, so if any open PR still
308/// targets `branch` as its base we must NOT delete it — warn and return true to
309/// skip the deletion. On a query error we conservatively skip too, rather than
310/// risk silently closing a child PR.
311fn remote_deletion_blocked_by_children(branch: &str) -> bool {
312    match github::open_prs_with_base(branch) {
313        Ok(children) if !children.is_empty() => {
314            output::warn(&format!(
315                "Not deleting origin/{branch}: {} open PR(s) still target it as base:",
316                children.len()
317            ));
318            for child in &children {
319                output::warn(&format!("  #{} ({})", child.number, child.head_branch));
320            }
321            output::action(
322                "gw sync   # run on each child to restack onto main, then re-run gw cleanup",
323            );
324            true
325        }
326        Ok(_) => false,
327        Err(e) => {
328            output::warn(&format!("Could not check for dependent PRs: {e}"));
329            output::warn(&format!(
330                "Not deleting origin/{branch} to avoid closing a child PR."
331            ));
332            output::action(&format!(
333                "git push origin --delete {branch}  # if you're sure nothing depends on it"
334            ));
335            true
336        }
337    }
338}
339
340/// Handle remote branch deletion
341fn handle_remote_branch(branch: &str, pr_info: &Option<github::PrInfo>, verbose: bool) {
342    let remote_exists = match git::remote_branch_exists(branch) {
343        Ok(v) => v,
344        Err(e) => {
345            // Couldn't reach the remote — don't claim it was "already deleted".
346            output::warn(&format!(
347                "Could not verify remote branch origin/{branch}: {e}"
348            ));
349            output::action(&format!(
350                "git push origin --delete {branch}  # if it still exists"
351            ));
352            return;
353        }
354    };
355
356    if !remote_exists {
357        // Remote branch already deleted (GitHub auto-delete after merge)
358        if let Some(pr) = pr_info {
359            if matches!(pr.state, PrState::Merged { .. }) {
360                output::success("Remote branch already deleted by GitHub");
361            }
362        }
363        return;
364    }
365
366    // Remote branch still exists
367    match pr_info {
368        Some(pr) if matches!(pr.state, PrState::Merged { .. }) => {
369            // Don't delete a branch that open PRs still use as their base —
370            // GitHub would close those child PRs. Skip the remote deletion
371            // (local cleanup already happened) and let the user restack first.
372            if remote_deletion_blocked_by_children(branch) {
373                return;
374            }
375            // PR merged but remote branch exists - delete it
376            output::info("PR merged, deleting remote branch...");
377            match github::delete_remote_branch(branch) {
378                Ok(()) => {
379                    output::success(&format!(
380                        "Deleted remote branch origin/{}",
381                        output::bold(branch)
382                    ));
383                }
384                Err(e) => {
385                    output::warn(&format!("Failed to delete remote branch: {}", e));
386                    output::action(&format!("git push origin --delete {}", branch));
387                }
388            }
389        }
390        Some(pr) if matches!(pr.state, PrState::Open) => {
391            output::warn(&format!(
392                "Remote branch exists and PR #{} is still open",
393                pr.number
394            ));
395            output::action(&format!("gh pr view {}", pr.number));
396        }
397        _ => {
398            output::warn(&format!("Remote branch still exists: origin/{}", branch));
399            if verbose {
400                output::action(&format!("git push origin --delete {}", branch));
401            }
402        }
403    }
404}