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 stash -u -m 'WIP before cleanup'");
67            output::action("git status");
68            return Err(GwError::UncommittedChanges);
69        }
70    }
71
72    // Query PR information from GitHub
73    let pr_info = query_pr_info(&branch_to_delete);
74    let force_delete_allowed = should_allow_force_delete(&pr_info);
75
76    // Safety check: unpushed commits (skip if PR is merged)
77    if branch_exists && !force_delete_allowed {
78        check_unpushed_commits(&branch_to_delete)?;
79    }
80
81    // Fetch and prune
82    output::info("Fetching from origin...");
83    git::fetch_prune(verbose)?;
84    output::success("Fetched");
85
86    // Detect default remote branch
87    let default_remote = git::get_default_remote_branch()?;
88    let default_branch = default_remote.strip_prefix("origin/").unwrap_or("main");
89
90    // Switch to home branch first (only if on the branch to delete)
91    if needs_switch {
92        if !git::branch_exists(home_branch) {
93            git::checkout_new_branch(home_branch, &default_remote, verbose)?;
94            output::success(&format!(
95                "Created and switched to {}",
96                output::bold(home_branch)
97            ));
98        } else {
99            git::checkout(home_branch, verbose)?;
100            output::success(&format!("Switched to {}", output::bold(home_branch)));
101        }
102
103        // Sync home branch with default remote (only after switching).
104        // Fast-forward only: if home has diverged from origin we stop rather
105        // than silently creating a merge commit.
106        helpers::pull_with_output(&default_remote, default_branch, verbose)?;
107    }
108
109    // Delete the local branch
110    if branch_exists {
111        delete_local_branch(
112            deletable_branch,
113            &branch_to_delete,
114            force_delete_allowed,
115            verbose,
116        );
117    }
118
119    // Handle remote branch
120    handle_remote_branch(&branch_to_delete, &pr_info, verbose);
121
122    // Check for stashes
123    let stash_count = git::stash_count();
124    if stash_count > 0 {
125        output::warn(&format!(
126            "You have {} stash(es). Don't forget about them:",
127            stash_count
128        ));
129        output::action("git stash list");
130    }
131
132    if needs_switch {
133        output::ready("Cleanup complete", home_branch);
134        output::hints(&["gw new feature/your-feature  # Create new branch"]);
135    } else {
136        output::success(&format!(
137            "Cleanup complete (stayed on {})",
138            output::bold(&current)
139        ));
140    }
141
142    Ok(())
143}
144
145/// Query PR information from GitHub
146fn query_pr_info(branch: &str) -> Option<github::PrInfo> {
147    if !github::is_gh_available() {
148        output::info("GitHub CLI (gh) not available, skipping PR lookup");
149        return None;
150    }
151
152    output::info("Checking PR status...");
153
154    match github::get_pr_for_branch(branch) {
155        Ok(Some(pr)) => {
156            display_pr_info(&pr);
157            Some(pr)
158        }
159        Ok(None) => {
160            output::info("No PR found for this branch");
161            None
162        }
163        Err(e) => {
164            output::warn(&format!("Could not fetch PR info: {}", e));
165            None
166        }
167    }
168}
169
170/// Display PR information
171fn display_pr_info(pr: &github::PrInfo) {
172    let state_display = match &pr.state {
173        PrState::Open => "OPEN".to_string(),
174        PrState::Merged { method, .. } => format!("MERGED ({})", method),
175        PrState::Closed => "CLOSED".to_string(),
176    };
177
178    output::success(&format!(
179        "PR #{}: {} [{}]",
180        pr.number, pr.title, state_display
181    ));
182}
183
184/// Determine if force delete should be allowed based on PR state.
185///
186/// `cleanup` is the "tidy up a merged branch" command, not "throw away local
187/// work". So force delete is allowed ONLY when we can positively confirm the PR
188/// was merged. Every other situation — open PR, closed-without-merge, no PR
189/// found, gh unavailable, or a network failure querying GitHub — is treated as
190/// "could not confirm it's safe", and we refuse to force-delete. The caller
191/// still attempts a plain `git branch -d`; if that fails the user is told to
192/// discard the work explicitly (`git branch -D` or `gw abandon`).
193fn should_allow_force_delete(pr_info: &Option<github::PrInfo>) -> bool {
194    match pr_info {
195        Some(pr) => match &pr.state {
196            PrState::Merged { method, .. } => {
197                output::info(&format!("PR was {} merged, safe to force delete", method));
198                true
199            }
200            PrState::Open => {
201                output::warn("PR is still OPEN, be careful!");
202                false
203            }
204            PrState::Closed => {
205                output::warn("PR was closed without merging");
206                false
207            }
208        },
209        None => {
210            // No merged PR could be confirmed (no PR, gh unavailable, or lookup
211            // failed). Not safe to discard local commits automatically.
212            output::warn("No merged PR confirmed; will not force-delete unmerged commits");
213            false
214        }
215    }
216}
217
218/// Check for unpushed commits
219fn check_unpushed_commits(branch: &str) -> Result<()> {
220    if git::has_remote_tracking(branch) {
221        let sync_state = SyncState::detect(branch)?;
222        if sync_state.has_unpushed() {
223            let count = sync_state.unpushed_count();
224            output::error(&format!(
225                "Branch '{}' has {} unpushed commit(s)!",
226                branch, count
227            ));
228            println!();
229
230            // Show unpushed commits
231            if let Ok(commits) =
232                git::log_commits(&format!("{}@{{upstream}}", branch), branch, false)
233            {
234                println!("Unpushed commits:");
235                for commit in commits.iter().take(5) {
236                    println!("  {commit}");
237                }
238                println!();
239            }
240
241            output::action(&format!("git push origin {}  # Push first", branch));
242            output::action(&format!(
243                "git branch -D {}    # Or force delete (lose commits)",
244                branch
245            ));
246            return Err(GwError::UnpushedCommits(branch.to_string(), count));
247        }
248    } else {
249        // No remote tracking. Be conservative: only claim the work is safe
250        // (remote copy exists) when we can positively confirm it.
251        match git::remote_branch_exists(branch) {
252            Ok(true) => {
253                output::info("Branch has no tracking but remote exists (PR probably merged)")
254            }
255            Ok(false) => {
256                output::warn(&format!("Branch '{}' was never pushed to remote", branch));
257                output::warn("Commits on this branch will be lost if deleted");
258            }
259            Err(e) => {
260                output::warn(&format!("Could not verify remote for '{}': {}", branch, e));
261                output::warn("Commits on this branch may be lost if deleted");
262            }
263        }
264    }
265    Ok(())
266}
267
268/// Delete local branch, using force delete if allowed
269fn delete_local_branch(
270    deletable_branch: crate::state::Branch<crate::state::Deletable>,
271    branch_name: &str,
272    force_allowed: bool,
273    verbose: bool,
274) {
275    match deletable_branch.delete(verbose) {
276        Ok(()) => {
277            output::success(&format!(
278                "Deleted local branch {}",
279                output::bold(branch_name)
280            ));
281        }
282        Err(_) => {
283            if force_allowed {
284                // PR was merged, safe to force delete
285                output::info(
286                    "Branch not fully merged locally, but PR was merged. Force deleting...",
287                );
288                if let Err(e) = git::force_delete_branch(branch_name, verbose) {
289                    output::warn(&format!("Force delete failed: {}", e));
290                } else {
291                    output::success(&format!(
292                        "Force deleted local branch {}",
293                        output::bold(branch_name)
294                    ));
295                }
296            } else {
297                output::warn("Branch not fully merged. Use -D to force delete:");
298                output::action(&format!("git branch -D {}", branch_name));
299            }
300        }
301    }
302}
303
304/// Handle remote branch deletion
305fn handle_remote_branch(branch: &str, pr_info: &Option<github::PrInfo>, verbose: bool) {
306    let remote_exists = match git::remote_branch_exists(branch) {
307        Ok(v) => v,
308        Err(e) => {
309            // Couldn't reach the remote — don't claim it was "already deleted".
310            output::warn(&format!(
311                "Could not verify remote branch origin/{branch}: {e}"
312            ));
313            output::action(&format!(
314                "git push origin --delete {branch}  # if it still exists"
315            ));
316            return;
317        }
318    };
319
320    if !remote_exists {
321        // Remote branch already deleted (GitHub auto-delete after merge)
322        if let Some(pr) = pr_info {
323            if matches!(pr.state, PrState::Merged { .. }) {
324                output::success("Remote branch already deleted by GitHub");
325            }
326        }
327        return;
328    }
329
330    // Remote branch still exists
331    match pr_info {
332        Some(pr) if matches!(pr.state, PrState::Merged { .. }) => {
333            // PR merged but remote branch exists - delete it
334            output::info("PR merged, deleting remote branch...");
335            match github::delete_remote_branch(branch) {
336                Ok(()) => {
337                    output::success(&format!(
338                        "Deleted remote branch origin/{}",
339                        output::bold(branch)
340                    ));
341                }
342                Err(e) => {
343                    output::warn(&format!("Failed to delete remote branch: {}", e));
344                    output::action(&format!("git push origin --delete {}", branch));
345                }
346            }
347        }
348        Some(pr) if matches!(pr.state, PrState::Open) => {
349            output::warn(&format!(
350                "Remote branch exists and PR #{} is still open",
351                pr.number
352            ));
353            output::action(&format!("gh pr view {}", pr.number));
354        }
355        _ => {
356            output::warn(&format!("Remote branch still exists: origin/{}", branch));
357            if verbose {
358                output::action(&format!("git push origin --delete {}", branch));
359            }
360        }
361    }
362}