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