1use 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
12pub fn run(branch_name: Option<String>, verbose: bool) -> Result<()> {
14 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 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 let needs_switch = current == branch_to_delete;
43
44 let branch = classify_branch(&branch_to_delete, &repo_type);
46 let deletable_branch = branch.try_deletable()?;
47
48 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 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 let pr_info = query_pr_info(&branch_to_delete);
75 let force_delete_allowed = should_allow_force_delete(&pr_info);
76
77 if branch_exists && !force_delete_allowed {
79 check_unpushed_commits(&branch_to_delete)?;
80 }
81
82 output::info("Fetching from origin...");
84 git::fetch_prune(verbose)?;
85 output::success("Fetched");
86
87 let default_remote = git::get_default_remote_branch()?;
89 let default_branch = default_remote.strip_prefix("origin/").unwrap_or("main");
90
91 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 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 helpers::pull_with_output(&default_remote, default_branch, verbose)?;
113 }
114
115 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(&branch_to_delete, &pr_info, verbose);
127
128 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(¤t)
145 ));
146 }
147
148 Ok(())
149}
150
151fn 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
176fn 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
190fn 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 output::warn("No merged PR confirmed; will not force-delete unmerged commits");
219 false
220 }
221 }
222}
223
224fn 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 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 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
274fn 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 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
310fn 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
345fn 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 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 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 match pr_info {
373 Some(pr) if matches!(pr.state, PrState::Merged { .. }) => {
374 if remote_deletion_blocked_by_children(branch) {
378 return;
379 }
380 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}