1use anyhow::Result;
2use clap::ArgAction;
3use clap_complete::engine::ArgValueCompleter;
4
5use crate::commands::Run;
6use crate::completions;
7use crate::providers::{
8 BaseGap, ReviewProvider, ReviewState, detect_review_provider, owned_review_for_branch,
9};
10use crate::settings;
11use crate::style;
12use crate::{git, stack};
13
14#[derive(Debug, clap::Args)]
25pub struct Cleanup {
26 #[arg(add = ArgValueCompleter::new(completions::branch_candidates))]
28 branch: Option<String>,
29 #[arg(long, short = 'n', action = ArgAction::SetTrue)]
31 dry_run: bool,
32 #[arg(long, action = ArgAction::SetTrue)]
34 keep_branch: bool,
35}
36
37#[derive(Debug, Clone, Copy, Eq, PartialEq)]
42pub(crate) enum Landing {
43 Merged,
44 Closed,
45}
46
47pub(crate) fn landing_for(state: &ReviewState, clean_closed: bool) -> Option<Landing> {
51 match state {
52 ReviewState::Merged => Some(Landing::Merged),
53 ReviewState::Closed if clean_closed => Some(Landing::Closed),
54 _ => None,
55 }
56}
57
58impl Run for Cleanup {
59 fn run(self) -> Result<()> {
60 cleanup(self.branch.as_deref(), self.dry_run, self.keep_branch)
61 }
62}
63
64pub fn cleanup(branch: Option<&str>, dry_run: bool, keep_branch: bool) -> Result<()> {
65 let branch = branch
66 .map(str::to_owned)
67 .map_or_else(git::current_branch, Ok)?;
68 let branches = stack::branch_and_descendants(&branch)?;
69 let current_branch = git::current_branch()?;
70 let local_branches = git::local_branches()?;
71 let (provider, review_provider) = detect_review_provider()?;
72 let clean_closed = settings::bool_setting(settings::CLEAN_CLOSED_KEY)?;
73 let mut cleaned = 0;
74 let mut skipped = 0;
75 let mut kept = 0;
76 let mut retargeted = 0;
77
78 if !dry_run {
80 stack::snapshot("cleanup");
81 }
82
83 let branch_parents = stack::branch_parents(&branches)?;
87 crate::notes::update_stack_notes(review_provider.as_ref(), &branch_parents, dry_run, false)?;
88
89 for branch in branches {
90 if stack::is_floor(&branch)? {
94 anstream::println!(
95 "{}",
96 style::dim(&format!("skipped {branch}: this stack's base"))
97 );
98 skipped += 1;
99 continue;
100 }
101
102 retargeted += recover_deleted_parent(
103 review_provider.as_ref(),
104 &branch,
105 &local_branches,
106 clean_closed,
107 dry_run,
108 )?;
109 let Some(review) = review_provider.review_for_branch_including_closed(&branch)? else {
113 anstream::println!(
114 "{}",
115 style::dim(&format!(
116 "skipped {branch}: no {} review found",
117 provider.kind
118 ))
119 );
120 skipped += 1;
121 continue;
122 };
123
124 let Some(landing) = landing_for(&review.state, clean_closed) else {
125 anstream::println!(
126 "{}",
127 style::dim(&format!(
128 "skipped {branch}: review {} is {}",
129 review.id, review.state
130 ))
131 );
132 skipped += 1;
133 continue;
134 };
135
136 if !keep_branch && let Some(reason) = deletion_blocker(&branch, ¤t_branch)? {
140 report_kept(&branch, &reason);
141 kept += 1;
142 continue;
143 }
144
145 cleanup_finished_branch(review_provider.as_ref(), &branch, landing, dry_run)?;
146 if !keep_branch {
147 cleanup_branch_deletion(&branch, landing, dry_run)?;
148 }
149 cleaned += 1;
150 }
151
152 let kept_note = if kept > 0 {
155 format!(", {kept} kept")
156 } else {
157 String::new()
158 };
159 let retargeted_note = if retargeted > 0 {
160 format!(", {retargeted} retargeted")
161 } else {
162 String::new()
163 };
164 anstream::println!(
165 "{}",
166 style::success(&format!(
167 "cleanup complete: {cleaned} cleaned, {skipped} skipped{kept_note}{retargeted_note}"
168 ))
169 );
170 Ok(())
171}
172
173fn recover_deleted_parent(
177 review_provider: &dyn ReviewProvider,
178 branch: &str,
179 local_branches: &[String],
180 clean_closed: bool,
181 dry_run: bool,
182) -> Result<usize> {
183 let Some(parent) = stack::parent_of(branch)? else {
184 return Ok(0);
185 };
186 if local_branches.contains(&parent) {
187 return Ok(0);
188 }
189
190 let Ok(Some(review)) = owned_review_for_branch(review_provider, &parent) else {
193 return Ok(0);
194 };
195 let Some(landing) = landing_for(&review.state, clean_closed) else {
196 return Ok(0);
197 };
198 if stack::is_floor(&parent)? {
200 return Ok(0);
201 }
202 if review.base == *branch || !local_branches.contains(&review.base) {
203 return Ok(0);
204 }
205
206 match landing {
207 Landing::Merged => anstream::println!(
208 "{}: parent {} is gone, but review {} merged into {}",
209 style::branch(branch),
210 style::branch(&parent),
211 review.id,
212 style::branch(&review.base)
213 ),
214 Landing::Closed => anstream::println!(
215 "{}: parent {} is gone; review {} was closed against {}",
216 style::branch(branch),
217 style::branch(&parent),
218 review.id,
219 style::branch(&review.base)
220 ),
221 }
222 anstream::println!(
223 "{} retarget {} -> {}",
224 if dry_run { "would" } else { "will" },
225 style::branch(branch),
226 style::branch(&review.base)
227 );
228 update_child_review_base(review_provider, branch, &review.base, dry_run)?;
229 if !dry_run {
230 if landing == Landing::Closed {
235 stack::unset_base(branch)?;
236 }
237 stack::set_parent(branch, &review.base)?;
238 }
239 Ok(1)
240}
241
242pub(crate) fn cleanup_finished_branch(
246 review_provider: &dyn ReviewProvider,
247 branch: &str,
248 landing: Landing,
249 dry_run: bool,
250) -> Result<()> {
251 let parent = stack::parent_of(branch)?;
252 let descendants = stack::branch_and_descendants(branch)?;
253 let direct_children: Vec<_> = descendants
254 .into_iter()
255 .skip(1)
256 .filter_map(|child| match stack::parent_of(&child) {
257 Ok(Some(child_parent)) if child_parent == branch => Some(Ok(child)),
258 Ok(_) => None,
259 Err(error) => Some(Err(error)),
260 })
261 .collect::<Result<_>>()?;
262
263 for child in direct_children {
264 match parent.as_deref() {
265 Some(parent) => {
266 anstream::println!(
267 "{} retarget {} -> {}",
268 if dry_run { "would" } else { "will" },
269 style::branch(&child),
270 style::branch(parent)
271 );
272 update_child_review_base(review_provider, &child, parent, dry_run)?;
273 if !dry_run {
274 match landing {
275 Landing::Merged => {
279 if let Ok(base) = git::merge_base(branch, &child) {
280 stack::set_base(&child, &base)?;
281 }
282 }
283 Landing::Closed => stack::unset_base(&child)?,
289 }
290 stack::set_parent(&child, parent)?;
291 }
292 }
293 None => {
294 anstream::println!(
295 "{} detach {}",
296 if dry_run { "would" } else { "will" },
297 style::branch(&child)
298 );
299 if !dry_run {
300 stack::unset_parent(&child)?;
301 stack::unset_base(&child)?;
302 }
303 }
304 }
305 }
306 anstream::println!(
307 "{} detach {}",
308 if dry_run { "would" } else { "will" },
309 style::branch(branch)
310 );
311 if !dry_run {
312 stack::unset_parent(branch)?;
313 stack::unset_base(branch)?;
314 }
315
316 Ok(())
317}
318
319pub(crate) fn deletion_blocker(branch: &str, current_branch: &str) -> Result<Option<String>> {
324 if branch == current_branch {
327 return Ok(Some("cannot delete the checked out branch".to_owned()));
328 }
329
330 let Some(path) = git::worktree_holding(branch)? else {
333 return Ok(None);
334 };
335 if !stack::owned_worktree(branch).is_some_and(|owned| git::same_path(&owned, &path)) {
336 return Ok(Some(format!(
340 "checked out in the worktree at {}",
341 git::display_path(&path)
342 )));
343 }
344 if git::worktree_has_changes(&path) {
347 return Ok(Some(format!(
348 "its worktree at {} has uncommitted changes",
349 git::display_path(&path)
350 )));
351 }
352 Ok(None)
353}
354
355pub(crate) fn report_kept(branch: &str, reason: &str) {
357 anstream::println!(
358 "{}",
359 style::dim(&format!(
360 "kept {branch}: {reason} - still stacked, so a later cleanup can finish it"
361 ))
362 );
363}
364
365pub(crate) fn cleanup_branch_deletion(branch: &str, landing: Landing, dry_run: bool) -> Result<()> {
369 if let Some(path) = stack::owned_worktree(branch).filter(|owned| {
370 git::worktree_holding(branch)
371 .ok()
372 .flatten()
373 .is_some_and(|held| git::same_path(owned, &held))
374 }) {
375 anstream::println!(
376 "{} remove worktree {}",
377 if dry_run { "would" } else { "will" },
378 git::display_path(&path)
379 );
380 if !dry_run {
381 git::worktree_remove(&path)?;
382 stack::unset_owned_worktree(branch)?;
383 }
384 }
385
386 let caveat = match landing {
390 Landing::Merged => String::new(),
391 Landing::Closed => style::dim(" (closed, not merged - `git stk undo` restores it)"),
392 };
393 anstream::println!(
394 "{} delete branch {}{caveat}",
395 if dry_run { "would" } else { "will" },
396 style::branch(branch)
397 );
398 if !dry_run {
399 git::delete_branch(branch)?;
400 }
401
402 Ok(())
403}
404
405fn update_child_review_base(
406 review_provider: &dyn ReviewProvider,
407 child: &str,
408 parent: &str,
409 dry_run: bool,
410) -> Result<()> {
411 let Some(review) = review_provider.review_for_branch(child)? else {
412 return Ok(());
413 };
414
415 if review.state == ReviewState::Merged || review.base == parent {
416 return Ok(());
417 }
418
419 match review_provider.base_gap(&review, parent)? {
424 Some(BaseGap::Platform) => {
425 anstream::println!(
426 "{}",
427 style::dim(&format!(
428 "{} is in a stack; the platform retargets it as the stack lands",
429 review.id
430 ))
431 );
432 return Ok(());
433 }
434 Some(BaseGap::Sync) => {
438 anstream::println!(
439 "{}",
440 style::warn(&format!(
441 "{} already targets {} - the platform moved it when {parent} landed; \
442 run `git stk sync` to catch the local stack up",
443 review.id, review.base
444 ))
445 );
446 return Ok(());
447 }
448 Some(BaseGap::Neither) => {
449 anstream::println!(
450 "{}",
451 style::warn(&format!(
452 "{} still targets {} and its stack will not move it to {parent} - the \
453 platform refuses a change by hand too; run `git stk unstack`, \
454 then `git stk submit`",
455 review.id, review.base
456 ))
457 );
458 return Ok(());
459 }
460 None => {}
461 }
462
463 anstream::println!(
464 "{} update review {} -> {} {}",
465 if dry_run { "would" } else { "will" },
466 style::branch(&review.branch),
467 style::branch(parent),
468 style::dim(&format!("({})", review.id))
469 );
470 if !dry_run {
471 let output = review_provider.update_review_base(&review, parent)?;
472 if !output.is_empty() {
473 println!("{output}");
474 }
475 }
476
477 Ok(())
478}