git_workflow/commands/
cleanup.rs1use crate::error::{GwError, Result};
6use crate::git;
7use crate::github::{self, PrState};
8use crate::output;
9use crate::state::{RepoType, SyncState, WorkingDirState, classify_branch};
10
11pub fn run(branch_name: Option<String>, verbose: bool) -> Result<()> {
13 if !git::is_git_repo() {
15 return Err(GwError::NotAGitRepository);
16 }
17
18 let repo_type = RepoType::detect()?;
19 let home_branch = repo_type.home_branch();
20 let current = git::current_branch()?;
21
22 let branch_to_delete = match branch_name {
24 Some(name) => name,
25 None => {
26 if current == home_branch {
27 return Err(GwError::AlreadyOnHomeBranch(home_branch.to_string()));
28 }
29 current.clone()
30 }
31 };
32
33 println!();
34 output::info(&format!(
35 "Branch to delete: {}",
36 output::bold(&branch_to_delete)
37 ));
38 output::info(&format!("Home branch: {}", output::bold(home_branch)));
39
40 let needs_switch = current == branch_to_delete;
42
43 let branch = classify_branch(&branch_to_delete, &repo_type);
45 let deletable_branch = branch.try_deletable()?;
46
47 let branch_exists = git::branch_exists(&branch_to_delete);
49 if !branch_exists {
50 output::warn(&format!(
51 "Branch '{}' does not exist locally",
52 branch_to_delete
53 ));
54 }
55
56 if needs_switch {
58 let working_dir = WorkingDirState::detect();
59 if !working_dir.is_clean() {
60 output::error(&format!(
61 "You have uncommitted changes ({}).",
62 working_dir.description()
63 ));
64 println!();
65 output::action("git stash -u -m 'WIP before cleanup'");
66 output::action("git status");
67 return Err(GwError::UncommittedChanges);
68 }
69 }
70
71 let pr_info = query_pr_info(&branch_to_delete);
73 let force_delete_allowed = should_allow_force_delete(&pr_info, &branch_to_delete);
74
75 if branch_exists && !force_delete_allowed {
77 check_unpushed_commits(&branch_to_delete)?;
78 }
79
80 output::info("Fetching from origin...");
82 git::fetch_prune(verbose)?;
83 output::success("Fetched");
84
85 let default_remote = git::get_default_remote_branch()?;
87 let default_branch = default_remote.strip_prefix("origin/").unwrap_or("main");
88
89 if needs_switch {
91 if !git::branch_exists(home_branch) {
92 git::checkout_new_branch(home_branch, &default_remote, verbose)?;
93 output::success(&format!(
94 "Created and switched to {}",
95 output::bold(home_branch)
96 ));
97 } else {
98 git::checkout(home_branch, verbose)?;
99 output::success(&format!("Switched to {}", output::bold(home_branch)));
100 }
101
102 output::info(&format!("Syncing with {}...", default_remote));
104 git::pull("origin", default_branch, verbose)?;
105 output::success("Synced");
106 }
107
108 if branch_exists {
110 delete_local_branch(
111 deletable_branch,
112 &branch_to_delete,
113 force_delete_allowed,
114 verbose,
115 );
116 }
117
118 handle_remote_branch(&branch_to_delete, &pr_info, verbose);
120
121 let stash_count = git::stash_count();
123 if stash_count > 0 {
124 output::warn(&format!(
125 "You have {} stash(es). Don't forget about them:",
126 stash_count
127 ));
128 output::action("git stash list");
129 }
130
131 if needs_switch {
132 output::ready("Cleanup complete", home_branch);
133 output::hints(&["mise run git:new feature/your-feature # Create new branch"]);
134 } else {
135 output::success(&format!(
136 "Cleanup complete (stayed on {})",
137 output::bold(¤t)
138 ));
139 }
140
141 Ok(())
142}
143
144fn query_pr_info(branch: &str) -> Option<github::PrInfo> {
146 if !github::is_gh_available() {
147 output::info("GitHub CLI (gh) not available, skipping PR lookup");
148 return None;
149 }
150
151 output::info("Checking PR status...");
152
153 match github::get_pr_for_branch(branch) {
154 Ok(Some(pr)) => {
155 display_pr_info(&pr);
156 Some(pr)
157 }
158 Ok(None) => {
159 output::info("No PR found for this branch");
160 None
161 }
162 Err(e) => {
163 output::warn(&format!("Could not fetch PR info: {}", e));
164 None
165 }
166 }
167}
168
169fn display_pr_info(pr: &github::PrInfo) {
171 let state_display = match &pr.state {
172 PrState::Open => "OPEN".to_string(),
173 PrState::Merged { method, .. } => format!("MERGED ({})", method),
174 PrState::Closed => "CLOSED".to_string(),
175 };
176
177 output::success(&format!(
178 "PR #{}: {} [{}]",
179 pr.number, pr.title, state_display
180 ));
181}
182
183fn should_allow_force_delete(pr_info: &Option<github::PrInfo>, branch: &str) -> bool {
185 match pr_info {
186 Some(pr) => match &pr.state {
187 PrState::Merged { method, .. } => {
188 output::info(&format!("PR was {} merged, safe to force delete", method));
189 true
190 }
191 PrState::Open => {
192 output::warn("PR is still OPEN, be careful!");
193 false
194 }
195 PrState::Closed => {
196 output::warn("PR was closed without merging");
197 false
198 }
199 },
200 None => {
201 if git::remote_branch_exists(branch) {
203 output::info("No PR found but remote branch exists");
204 false
205 } else {
206 true
208 }
209 }
210 }
211}
212
213fn check_unpushed_commits(branch: &str) -> Result<()> {
215 if git::has_remote_tracking(branch) {
216 let sync_state = SyncState::detect(branch)?;
217 if sync_state.has_unpushed() {
218 let count = sync_state.unpushed_count();
219 output::error(&format!(
220 "Branch '{}' has {} unpushed commit(s)!",
221 branch, count
222 ));
223 println!();
224
225 if let Ok(commits) =
227 git::log_commits(&format!("{}@{{upstream}}", branch), branch, false)
228 {
229 println!("Unpushed commits:");
230 for commit in commits.iter().take(5) {
231 println!(" {commit}");
232 }
233 println!();
234 }
235
236 output::action(&format!("git push origin {} # Push first", branch));
237 output::action(&format!(
238 "git branch -D {} # Or force delete (lose commits)",
239 branch
240 ));
241 return Err(GwError::UnpushedCommits(branch.to_string(), count));
242 }
243 } else {
244 if git::remote_branch_exists(branch) {
246 output::info("Branch has no tracking but remote exists (PR probably merged)");
247 } else {
248 output::warn(&format!("Branch '{}' was never pushed to remote", branch));
249 output::warn("Commits on this branch will be lost if deleted");
250 }
251 }
252 Ok(())
253}
254
255fn delete_local_branch(
257 deletable_branch: crate::state::Branch<crate::state::Deletable>,
258 branch_name: &str,
259 force_allowed: bool,
260 verbose: bool,
261) {
262 match deletable_branch.delete(verbose) {
263 Ok(()) => {
264 output::success(&format!(
265 "Deleted local branch {}",
266 output::bold(branch_name)
267 ));
268 }
269 Err(_) => {
270 if force_allowed {
271 output::info(
273 "Branch not fully merged locally, but PR was merged. Force deleting...",
274 );
275 if let Err(e) = git::force_delete_branch(branch_name, verbose) {
276 output::warn(&format!("Force delete failed: {}", e));
277 } else {
278 output::success(&format!(
279 "Force deleted local branch {}",
280 output::bold(branch_name)
281 ));
282 }
283 } else {
284 output::warn("Branch not fully merged. Use -D to force delete:");
285 output::action(&format!("git branch -D {}", branch_name));
286 }
287 }
288 }
289}
290
291fn handle_remote_branch(branch: &str, pr_info: &Option<github::PrInfo>, verbose: bool) {
293 let remote_exists = git::remote_branch_exists(branch);
294
295 if !remote_exists {
296 if let Some(pr) = pr_info {
298 if matches!(pr.state, PrState::Merged { .. }) {
299 output::success("Remote branch already deleted by GitHub");
300 }
301 }
302 return;
303 }
304
305 match pr_info {
307 Some(pr) if matches!(pr.state, PrState::Merged { .. }) => {
308 output::info("PR merged, deleting remote branch...");
310 match github::delete_remote_branch(branch) {
311 Ok(()) => {
312 output::success(&format!(
313 "Deleted remote branch origin/{}",
314 output::bold(branch)
315 ));
316 }
317 Err(e) => {
318 output::warn(&format!("Failed to delete remote branch: {}", e));
319 output::action(&format!("git push origin --delete {}", branch));
320 }
321 }
322 }
323 Some(pr) if matches!(pr.state, PrState::Open) => {
324 output::warn(&format!(
325 "Remote branch exists and PR #{} is still open",
326 pr.number
327 ));
328 output::action(&format!("gh pr view {}", pr.number));
329 }
330 _ => {
331 output::warn(&format!("Remote branch still exists: origin/{}", branch));
332 if verbose {
333 output::action(&format!("git push origin --delete {}", branch));
334 }
335 }
336 }
337}