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 retargeted = 0;
76
77 if !dry_run {
79 stack::snapshot("cleanup");
80 }
81
82 let branch_parents = stack::branch_parents(&branches)?;
86 crate::notes::update_stack_notes(review_provider.as_ref(), &branch_parents, dry_run, false)?;
87
88 for branch in branches {
89 retargeted += recover_deleted_parent(
90 review_provider.as_ref(),
91 &branch,
92 &local_branches,
93 clean_closed,
94 dry_run,
95 )?;
96 let Some(review) = review_provider.review_for_branch_including_closed(&branch)? else {
100 anstream::println!(
101 "{}",
102 style::dim(&format!(
103 "skipped {branch}: no {} review found",
104 provider.kind
105 ))
106 );
107 skipped += 1;
108 continue;
109 };
110
111 let Some(landing) = landing_for(&review.state, clean_closed) else {
112 anstream::println!(
113 "{}",
114 style::dim(&format!(
115 "skipped {branch}: review {} is {}",
116 review.id, review.state
117 ))
118 );
119 skipped += 1;
120 continue;
121 };
122
123 cleanup_finished_branch(review_provider.as_ref(), &branch, landing, dry_run)?;
124 cleanup_branch_deletion(&branch, ¤t_branch, landing, dry_run, !keep_branch)?;
125 cleaned += 1;
126 }
127
128 let retargeted_note = if retargeted > 0 {
129 format!(", {retargeted} retargeted")
130 } else {
131 String::new()
132 };
133 anstream::println!(
134 "{}",
135 style::success(&format!(
136 "cleanup complete: {cleaned} cleaned, {skipped} skipped{retargeted_note}"
137 ))
138 );
139 Ok(())
140}
141
142fn recover_deleted_parent(
146 review_provider: &dyn ReviewProvider,
147 branch: &str,
148 local_branches: &[String],
149 clean_closed: bool,
150 dry_run: bool,
151) -> Result<usize> {
152 let Some(parent) = stack::parent_of(branch)? else {
153 return Ok(0);
154 };
155 if local_branches.contains(&parent) {
156 return Ok(0);
157 }
158
159 let Ok(Some(review)) = owned_review_for_branch(review_provider, &parent) else {
162 return Ok(0);
163 };
164 let Some(landing) = landing_for(&review.state, clean_closed) else {
165 return Ok(0);
166 };
167 if review.base == *branch || !local_branches.contains(&review.base) {
168 return Ok(0);
169 }
170
171 match landing {
172 Landing::Merged => anstream::println!(
173 "{}: parent {} is gone, but review {} merged into {}",
174 style::branch(branch),
175 style::branch(&parent),
176 review.id,
177 style::branch(&review.base)
178 ),
179 Landing::Closed => anstream::println!(
180 "{}: parent {} is gone; review {} was closed against {}",
181 style::branch(branch),
182 style::branch(&parent),
183 review.id,
184 style::branch(&review.base)
185 ),
186 }
187 anstream::println!(
188 "{} retarget {} -> {}",
189 if dry_run { "would" } else { "will" },
190 style::branch(branch),
191 style::branch(&review.base)
192 );
193 update_child_review_base(review_provider, branch, &review.base, dry_run)?;
194 if !dry_run {
195 if landing == Landing::Closed {
200 stack::unset_base(branch)?;
201 }
202 stack::set_parent(branch, &review.base)?;
203 }
204 Ok(1)
205}
206
207pub(crate) fn cleanup_finished_branch(
211 review_provider: &dyn ReviewProvider,
212 branch: &str,
213 landing: Landing,
214 dry_run: bool,
215) -> Result<()> {
216 let parent = stack::parent_of(branch)?;
217 let descendants = stack::branch_and_descendants(branch)?;
218 let direct_children: Vec<_> = descendants
219 .into_iter()
220 .skip(1)
221 .filter_map(|child| match stack::parent_of(&child) {
222 Ok(Some(child_parent)) if child_parent == branch => Some(Ok(child)),
223 Ok(_) => None,
224 Err(error) => Some(Err(error)),
225 })
226 .collect::<Result<_>>()?;
227
228 for child in direct_children {
229 match parent.as_deref() {
230 Some(parent) => {
231 anstream::println!(
232 "{} retarget {} -> {}",
233 if dry_run { "would" } else { "will" },
234 style::branch(&child),
235 style::branch(parent)
236 );
237 update_child_review_base(review_provider, &child, parent, dry_run)?;
238 if !dry_run {
239 match landing {
240 Landing::Merged => {
244 if let Ok(base) = git::merge_base(branch, &child) {
245 stack::set_base(&child, &base)?;
246 }
247 }
248 Landing::Closed => stack::unset_base(&child)?,
254 }
255 stack::set_parent(&child, parent)?;
256 }
257 }
258 None => {
259 anstream::println!(
260 "{} detach {}",
261 if dry_run { "would" } else { "will" },
262 style::branch(&child)
263 );
264 if !dry_run {
265 stack::unset_parent(&child)?;
266 stack::unset_base(&child)?;
267 }
268 }
269 }
270 }
271 anstream::println!(
272 "{} detach {}",
273 if dry_run { "would" } else { "will" },
274 style::branch(branch)
275 );
276 if !dry_run {
277 stack::unset_parent(branch)?;
278 stack::unset_base(branch)?;
279 }
280
281 Ok(())
282}
283
284pub(crate) fn cleanup_branch_deletion(
285 branch: &str,
286 current_branch: &str,
287 landing: Landing,
288 dry_run: bool,
289 delete_branch: bool,
290) -> Result<()> {
291 if !delete_branch {
292 return Ok(());
293 }
294
295 if branch == current_branch {
298 anstream::println!(
299 "{}",
300 style::dim(&format!(
301 "kept {branch}: cannot delete the checked out branch"
302 ))
303 );
304 return Ok(());
305 }
306
307 if let Some(path) = git::worktree_holding(branch)? {
311 let ours = stack::owned_worktree(branch).is_some_and(|owned| git::same_path(&owned, &path));
312 if !ours {
313 anstream::println!(
317 "{}",
318 style::dim(&format!(
319 "kept {branch}: checked out in the worktree at {}",
320 git::display_path(&path)
321 ))
322 );
323 return Ok(());
324 }
325
326 if git::worktree_has_changes(&path) {
329 anstream::println!(
330 "{}",
331 style::dim(&format!(
332 "kept {branch}: its worktree at {} has uncommitted changes",
333 git::display_path(&path)
334 ))
335 );
336 return Ok(());
337 }
338
339 anstream::println!(
340 "{} remove worktree {}",
341 if dry_run { "would" } else { "will" },
342 git::display_path(&path)
343 );
344 if !dry_run {
345 git::worktree_remove(&path)?;
346 stack::unset_owned_worktree(branch)?;
347 }
348 }
349
350 let caveat = match landing {
354 Landing::Merged => String::new(),
355 Landing::Closed => style::dim(" (closed, not merged - `git stk undo` restores it)"),
356 };
357 anstream::println!(
358 "{} delete branch {}{caveat}",
359 if dry_run { "would" } else { "will" },
360 style::branch(branch)
361 );
362 if !dry_run {
363 git::delete_branch(branch)?;
364 }
365
366 Ok(())
367}
368
369fn update_child_review_base(
370 review_provider: &dyn ReviewProvider,
371 child: &str,
372 parent: &str,
373 dry_run: bool,
374) -> Result<()> {
375 let Some(review) = review_provider.review_for_branch(child)? else {
376 return Ok(());
377 };
378
379 if review.state == ReviewState::Merged || review.base == parent {
380 return Ok(());
381 }
382
383 anstream::println!(
384 "{} update review {} -> {} {}",
385 if dry_run { "would" } else { "will" },
386 style::branch(&review.branch),
387 style::branch(parent),
388 style::dim(&format!("({})", review.id))
389 );
390 if !dry_run {
391 let output = review_provider.update_review_base(&review, parent)?;
392 if !output.is_empty() {
393 println!("{output}");
394 }
395 }
396
397 Ok(())
398}