1use anyhow::Result;
2use clap::ArgAction;
3use clap_complete::engine::ArgValueCompleter;
4
5use crate::commands::Run;
6use crate::completions;
7use crate::providers::{
8 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 retargeted += recover_deleted_parent(
91 review_provider.as_ref(),
92 &branch,
93 &local_branches,
94 clean_closed,
95 dry_run,
96 )?;
97 let Some(review) = review_provider.review_for_branch_including_closed(&branch)? else {
101 anstream::println!(
102 "{}",
103 style::dim(&format!(
104 "skipped {branch}: no {} review found",
105 provider.kind
106 ))
107 );
108 skipped += 1;
109 continue;
110 };
111
112 let Some(landing) = landing_for(&review.state, clean_closed) else {
113 anstream::println!(
114 "{}",
115 style::dim(&format!(
116 "skipped {branch}: review {} is {}",
117 review.id, review.state
118 ))
119 );
120 skipped += 1;
121 continue;
122 };
123
124 if !keep_branch && let Some(reason) = deletion_blocker(&branch, ¤t_branch)? {
128 report_kept(&branch, &reason);
129 kept += 1;
130 continue;
131 }
132
133 cleanup_finished_branch(review_provider.as_ref(), &branch, landing, dry_run)?;
134 if !keep_branch {
135 cleanup_branch_deletion(&branch, landing, dry_run)?;
136 }
137 cleaned += 1;
138 }
139
140 let kept_note = if kept > 0 {
143 format!(", {kept} kept")
144 } else {
145 String::new()
146 };
147 let retargeted_note = if retargeted > 0 {
148 format!(", {retargeted} retargeted")
149 } else {
150 String::new()
151 };
152 anstream::println!(
153 "{}",
154 style::success(&format!(
155 "cleanup complete: {cleaned} cleaned, {skipped} skipped{kept_note}{retargeted_note}"
156 ))
157 );
158 Ok(())
159}
160
161fn recover_deleted_parent(
165 review_provider: &dyn ReviewProvider,
166 branch: &str,
167 local_branches: &[String],
168 clean_closed: bool,
169 dry_run: bool,
170) -> Result<usize> {
171 let Some(parent) = stack::parent_of(branch)? else {
172 return Ok(0);
173 };
174 if local_branches.contains(&parent) {
175 return Ok(0);
176 }
177
178 let Ok(Some(review)) = owned_review_for_branch(review_provider, &parent) else {
181 return Ok(0);
182 };
183 let Some(landing) = landing_for(&review.state, clean_closed) else {
184 return Ok(0);
185 };
186 if review.base == *branch || !local_branches.contains(&review.base) {
187 return Ok(0);
188 }
189
190 match landing {
191 Landing::Merged => anstream::println!(
192 "{}: parent {} is gone, but review {} merged into {}",
193 style::branch(branch),
194 style::branch(&parent),
195 review.id,
196 style::branch(&review.base)
197 ),
198 Landing::Closed => anstream::println!(
199 "{}: parent {} is gone; review {} was closed against {}",
200 style::branch(branch),
201 style::branch(&parent),
202 review.id,
203 style::branch(&review.base)
204 ),
205 }
206 anstream::println!(
207 "{} retarget {} -> {}",
208 if dry_run { "would" } else { "will" },
209 style::branch(branch),
210 style::branch(&review.base)
211 );
212 update_child_review_base(review_provider, branch, &review.base, dry_run)?;
213 if !dry_run {
214 if landing == Landing::Closed {
219 stack::unset_base(branch)?;
220 }
221 stack::set_parent(branch, &review.base)?;
222 }
223 Ok(1)
224}
225
226pub(crate) fn cleanup_finished_branch(
230 review_provider: &dyn ReviewProvider,
231 branch: &str,
232 landing: Landing,
233 dry_run: bool,
234) -> Result<()> {
235 let parent = stack::parent_of(branch)?;
236 let descendants = stack::branch_and_descendants(branch)?;
237 let direct_children: Vec<_> = descendants
238 .into_iter()
239 .skip(1)
240 .filter_map(|child| match stack::parent_of(&child) {
241 Ok(Some(child_parent)) if child_parent == branch => Some(Ok(child)),
242 Ok(_) => None,
243 Err(error) => Some(Err(error)),
244 })
245 .collect::<Result<_>>()?;
246
247 for child in direct_children {
248 match parent.as_deref() {
249 Some(parent) => {
250 anstream::println!(
251 "{} retarget {} -> {}",
252 if dry_run { "would" } else { "will" },
253 style::branch(&child),
254 style::branch(parent)
255 );
256 update_child_review_base(review_provider, &child, parent, dry_run)?;
257 if !dry_run {
258 match landing {
259 Landing::Merged => {
263 if let Ok(base) = git::merge_base(branch, &child) {
264 stack::set_base(&child, &base)?;
265 }
266 }
267 Landing::Closed => stack::unset_base(&child)?,
273 }
274 stack::set_parent(&child, parent)?;
275 }
276 }
277 None => {
278 anstream::println!(
279 "{} detach {}",
280 if dry_run { "would" } else { "will" },
281 style::branch(&child)
282 );
283 if !dry_run {
284 stack::unset_parent(&child)?;
285 stack::unset_base(&child)?;
286 }
287 }
288 }
289 }
290 anstream::println!(
291 "{} detach {}",
292 if dry_run { "would" } else { "will" },
293 style::branch(branch)
294 );
295 if !dry_run {
296 stack::unset_parent(branch)?;
297 stack::unset_base(branch)?;
298 }
299
300 Ok(())
301}
302
303pub(crate) fn deletion_blocker(branch: &str, current_branch: &str) -> Result<Option<String>> {
308 if branch == current_branch {
311 return Ok(Some("cannot delete the checked out branch".to_owned()));
312 }
313
314 let Some(path) = git::worktree_holding(branch)? else {
317 return Ok(None);
318 };
319 if !stack::owned_worktree(branch).is_some_and(|owned| git::same_path(&owned, &path)) {
320 return Ok(Some(format!(
324 "checked out in the worktree at {}",
325 git::display_path(&path)
326 )));
327 }
328 if git::worktree_has_changes(&path) {
331 return Ok(Some(format!(
332 "its worktree at {} has uncommitted changes",
333 git::display_path(&path)
334 )));
335 }
336 Ok(None)
337}
338
339pub(crate) fn report_kept(branch: &str, reason: &str) {
341 anstream::println!(
342 "{}",
343 style::dim(&format!(
344 "kept {branch}: {reason} - still stacked, so a later cleanup can finish it"
345 ))
346 );
347}
348
349pub(crate) fn cleanup_branch_deletion(branch: &str, landing: Landing, dry_run: bool) -> Result<()> {
353 if let Some(path) = stack::owned_worktree(branch).filter(|owned| {
354 git::worktree_holding(branch)
355 .ok()
356 .flatten()
357 .is_some_and(|held| git::same_path(owned, &held))
358 }) {
359 anstream::println!(
360 "{} remove worktree {}",
361 if dry_run { "would" } else { "will" },
362 git::display_path(&path)
363 );
364 if !dry_run {
365 git::worktree_remove(&path)?;
366 stack::unset_owned_worktree(branch)?;
367 }
368 }
369
370 let caveat = match landing {
374 Landing::Merged => String::new(),
375 Landing::Closed => style::dim(" (closed, not merged - `git stk undo` restores it)"),
376 };
377 anstream::println!(
378 "{} delete branch {}{caveat}",
379 if dry_run { "would" } else { "will" },
380 style::branch(branch)
381 );
382 if !dry_run {
383 git::delete_branch(branch)?;
384 }
385
386 Ok(())
387}
388
389fn update_child_review_base(
390 review_provider: &dyn ReviewProvider,
391 child: &str,
392 parent: &str,
393 dry_run: bool,
394) -> Result<()> {
395 let Some(review) = review_provider.review_for_branch(child)? else {
396 return Ok(());
397 };
398
399 if review.state == ReviewState::Merged || review.base == parent {
400 return Ok(());
401 }
402
403 anstream::println!(
404 "{} update review {} -> {} {}",
405 if dry_run { "would" } else { "will" },
406 style::branch(&review.branch),
407 style::branch(parent),
408 style::dim(&format!("({})", review.id))
409 );
410 if !dry_run {
411 let output = review_provider.update_review_base(&review, parent)?;
412 if !output.is_empty() {
413 println!("{output}");
414 }
415 }
416
417 Ok(())
418}