git_workflow/commands/
cleanup.rs1use 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 git::checkout(home_branch, verbose)?;
101 output::success(&format!("Switched to {}", output::bold(home_branch)));
102 }
103
104 helpers::pull_with_output(&default_remote, default_branch, verbose)?;
108 }
109
110 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(&branch_to_delete, &pr_info, verbose);
122
123 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(¤t)
140 ));
141 }
142
143 Ok(())
144}
145
146fn 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
171fn 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
185fn 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 output::warn("No merged PR confirmed; will not force-delete unmerged commits");
214 false
215 }
216 }
217}
218
219fn 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 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 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
269fn 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 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
305fn 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
340fn 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 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 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 match pr_info {
368 Some(pr) if matches!(pr.state, PrState::Merged { .. }) => {
369 if remote_deletion_blocked_by_children(branch) {
373 return;
374 }
375 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}