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 stash -u -m 'WIP before cleanup'");
67 output::action("git status");
68 return Err(GwError::UncommittedChanges);
69 }
70 }
71
72 let pr_info = query_pr_info(&branch_to_delete);
74 let force_delete_allowed = should_allow_force_delete(&pr_info);
75
76 if branch_exists && !force_delete_allowed {
78 check_unpushed_commits(&branch_to_delete)?;
79 }
80
81 output::info("Fetching from origin...");
83 git::fetch_prune(verbose)?;
84 output::success("Fetched");
85
86 let default_remote = git::get_default_remote_branch()?;
88 let default_branch = default_remote.strip_prefix("origin/").unwrap_or("main");
89
90 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 helpers::pull_with_output(&default_remote, default_branch, verbose)?;
107 }
108
109 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(&branch_to_delete, &pr_info, verbose);
121
122 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(¤t)
139 ));
140 }
141
142 Ok(())
143}
144
145fn 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
170fn 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
184fn 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 output::warn("No merged PR confirmed; will not force-delete unmerged commits");
213 false
214 }
215 }
216}
217
218fn 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 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 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
268fn 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 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
304fn 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 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 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 match pr_info {
332 Some(pr) if matches!(pr.state, PrState::Merged { .. }) => {
333 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}