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            // Move the ref to the remote tip BEFORE the checkout so the
101            // working tree makes a single transition — otherwise the tree
102            // briefly rewinds to the pre-merge home (mass delete + re-add for
103            // anything watching the files) until the pull below catches up.
104            helpers::fast_forward_home_ref(home_branch, &default_remote, verbose);
105            git::checkout(home_branch, verbose)?;
106            output::success(&format!("Switched to {}", output::bold(home_branch)));
107        }
108
109        // Sync home branch with default remote (only after switching).
110        // Fast-forward only: if home has diverged from origin we stop rather
111        // than silently creating a merge commit.
112        helpers::pull_with_output(&default_remote, default_branch, verbose)?;
113    }
114
115    // Delete the local branch
116    if branch_exists {
117        delete_local_branch(
118            deletable_branch,
119            &branch_to_delete,
120            force_delete_allowed,
121            verbose,
122        );
123    }
124
125    // Handle remote branch
126    handle_remote_branch(&branch_to_delete, &pr_info, verbose);
127
128    // Check for stashes
129    let stash_count = git::stash_count();
130    if stash_count > 0 {
131        output::warn(&format!(
132            "You have {} stash(es). Don't forget about them:",
133            stash_count
134        ));
135        output::action("git stash list");
136    }
137
138    if needs_switch {
139        output::ready("Cleanup complete", home_branch);
140        output::hints(&["gw new feature/your-feature  # Create new branch"]);
141    } else {
142        output::success(&format!(
143            "Cleanup complete (stayed on {})",
144            output::bold(&current)
145        ));
146    }
147
148    Ok(())
149}
150
151/// Query PR information from GitHub
152fn query_pr_info(branch: &str) -> Option<github::PrInfo> {
153    if !github::is_gh_available() {
154        output::info("GitHub CLI (gh) not available, skipping PR lookup");
155        return None;
156    }
157
158    output::info("Checking PR status...");
159
160    match github::get_pr_for_branch(branch) {
161        Ok(Some(pr)) => {
162            display_pr_info(&pr);
163            Some(pr)
164        }
165        Ok(None) => {
166            output::info("No PR found for this branch");
167            None
168        }
169        Err(e) => {
170            output::warn(&format!("Could not fetch PR info: {}", e));
171            None
172        }
173    }
174}
175
176/// Display PR information
177fn display_pr_info(pr: &github::PrInfo) {
178    let state_display = match &pr.state {
179        PrState::Open => "OPEN".to_string(),
180        PrState::Merged { method, .. } => format!("MERGED ({})", method),
181        PrState::Closed => "CLOSED".to_string(),
182    };
183
184    output::success(&format!(
185        "PR #{}: {} [{}]",
186        pr.number, pr.title, state_display
187    ));
188}
189
190/// Determine if force delete should be allowed based on PR state.
191///
192/// `cleanup` is the "tidy up a merged branch" command, not "throw away local
193/// work". So force delete is allowed ONLY when we can positively confirm the PR
194/// was merged. Every other situation — open PR, closed-without-merge, no PR
195/// found, gh unavailable, or a network failure querying GitHub — is treated as
196/// "could not confirm it's safe", and we refuse to force-delete. The caller
197/// still attempts a plain `git branch -d`; if that fails the user is told to
198/// discard the work explicitly (`git branch -D` or `gw abandon`).
199fn should_allow_force_delete(pr_info: &Option<github::PrInfo>) -> bool {
200    match pr_info {
201        Some(pr) => match &pr.state {
202            PrState::Merged { method, .. } => {
203                output::info(&format!("PR was {} merged, safe to force delete", method));
204                true
205            }
206            PrState::Open => {
207                output::warn("PR is still OPEN, be careful!");
208                false
209            }
210            PrState::Closed => {
211                output::warn("PR was closed without merging");
212                false
213            }
214        },
215        None => {
216            // No merged PR could be confirmed (no PR, gh unavailable, or lookup
217            // failed). Not safe to discard local commits automatically.
218            output::warn("No merged PR confirmed; will not force-delete unmerged commits");
219            false
220        }
221    }
222}
223
224/// Check for unpushed commits
225fn check_unpushed_commits(branch: &str) -> Result<()> {
226    if git::has_remote_tracking(branch) {
227        let sync_state = SyncState::detect(branch)?;
228        if sync_state.has_unpushed() {
229            let count = sync_state.unpushed_count();
230            output::error(&format!(
231                "Branch '{}' has {} unpushed commit(s)!",
232                branch, count
233            ));
234            println!();
235
236            // Show unpushed commits
237            if let Ok(commits) =
238                git::log_commits(&format!("{}@{{upstream}}", branch), branch, false)
239            {
240                println!("Unpushed commits:");
241                for commit in commits.iter().take(5) {
242                    println!("  {commit}");
243                }
244                println!();
245            }
246
247            output::action(&format!("git push origin {}  # Push first", branch));
248            output::action(&format!(
249                "git branch -D {}    # Or force delete (lose commits)",
250                branch
251            ));
252            return Err(GwError::UnpushedCommits(branch.to_string(), count));
253        }
254    } else {
255        // No remote tracking. Be conservative: only claim the work is safe
256        // (remote copy exists) when we can positively confirm it.
257        match git::remote_branch_exists(branch) {
258            Ok(true) => {
259                output::info("Branch has no tracking but remote exists (PR probably merged)")
260            }
261            Ok(false) => {
262                output::warn(&format!("Branch '{}' was never pushed to remote", branch));
263                output::warn("Commits on this branch will be lost if deleted");
264            }
265            Err(e) => {
266                output::warn(&format!("Could not verify remote for '{}': {}", branch, e));
267                output::warn("Commits on this branch may be lost if deleted");
268            }
269        }
270    }
271    Ok(())
272}
273
274/// Delete local branch, using force delete if allowed
275fn delete_local_branch(
276    deletable_branch: crate::state::Branch<crate::state::Deletable>,
277    branch_name: &str,
278    force_allowed: bool,
279    verbose: bool,
280) {
281    match deletable_branch.delete(verbose) {
282        Ok(()) => {
283            output::success(&format!(
284                "Deleted local branch {}",
285                output::bold(branch_name)
286            ));
287        }
288        Err(_) => {
289            if force_allowed {
290                // PR was merged, safe to force delete
291                output::info(
292                    "Branch not fully merged locally, but PR was merged. Force deleting...",
293                );
294                if let Err(e) = git::force_delete_branch(branch_name, verbose) {
295                    output::warn(&format!("Force delete failed: {}", e));
296                } else {
297                    output::success(&format!(
298                        "Force deleted local branch {}",
299                        output::bold(branch_name)
300                    ));
301                }
302            } else {
303                output::warn("Branch not fully merged. Use -D to force delete:");
304                output::action(&format!("git branch -D {}", branch_name));
305            }
306        }
307    }
308}
309
310/// Whether deleting `branch`'s remote would orphan open child PRs.
311///
312/// GitHub closes a PR when its base branch is deleted, so if any open PR still
313/// targets `branch` as its base we must NOT delete it — warn and return true to
314/// skip the deletion. On a query error we conservatively skip too, rather than
315/// risk silently closing a child PR.
316fn remote_deletion_blocked_by_children(branch: &str) -> bool {
317    match github::open_prs_with_base(branch) {
318        Ok(children) if !children.is_empty() => {
319            output::warn(&format!(
320                "Not deleting origin/{branch}: {} open PR(s) still target it as base:",
321                children.len()
322            ));
323            for child in &children {
324                output::warn(&format!("  #{} ({})", child.number, child.head_branch));
325            }
326            output::action(
327                "gw sync   # run on each child to restack onto main, then re-run gw cleanup",
328            );
329            true
330        }
331        Ok(_) => false,
332        Err(e) => {
333            output::warn(&format!("Could not check for dependent PRs: {e}"));
334            output::warn(&format!(
335                "Not deleting origin/{branch} to avoid closing a child PR."
336            ));
337            output::action(&format!(
338                "git push origin --delete {branch}  # if you're sure nothing depends on it"
339            ));
340            true
341        }
342    }
343}
344
345/// Handle remote branch deletion
346fn handle_remote_branch(branch: &str, pr_info: &Option<github::PrInfo>, verbose: bool) {
347    let remote_exists = match git::remote_branch_exists(branch) {
348        Ok(v) => v,
349        Err(e) => {
350            // Couldn't reach the remote — don't claim it was "already deleted".
351            output::warn(&format!(
352                "Could not verify remote branch origin/{branch}: {e}"
353            ));
354            output::action(&format!(
355                "git push origin --delete {branch}  # if it still exists"
356            ));
357            return;
358        }
359    };
360
361    if !remote_exists {
362        // Remote branch already deleted (GitHub auto-delete after merge)
363        if let Some(pr) = pr_info {
364            if matches!(pr.state, PrState::Merged { .. }) {
365                output::success("Remote branch already deleted by GitHub");
366            }
367        }
368        return;
369    }
370
371    // Remote branch still exists
372    match pr_info {
373        Some(pr) if matches!(pr.state, PrState::Merged { .. }) => {
374            // Don't delete a branch that open PRs still use as their base —
375            // GitHub would close those child PRs. Skip the remote deletion
376            // (local cleanup already happened) and let the user restack first.
377            if remote_deletion_blocked_by_children(branch) {
378                return;
379            }
380            // PR merged but remote branch exists - delete it
381            output::info("PR merged, deleting remote branch...");
382            match github::delete_remote_branch(branch) {
383                Ok(()) => {
384                    output::success(&format!(
385                        "Deleted remote branch origin/{}",
386                        output::bold(branch)
387                    ));
388                }
389                Err(e) => {
390                    output::warn(&format!("Failed to delete remote branch: {}", e));
391                    output::action(&format!("git push origin --delete {}", branch));
392                }
393            }
394        }
395        Some(pr) if matches!(pr.state, PrState::Open) => {
396            output::warn(&format!(
397                "Remote branch exists and PR #{} is still open",
398                pr.number
399            ));
400            output::action(&format!("gh pr view {}", pr.number));
401        }
402        _ => {
403            output::warn(&format!("Remote branch still exists: origin/{}", branch));
404            if verbose {
405                output::action(&format!("git push origin --delete {}", branch));
406            }
407        }
408    }
409}