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::style;
11use crate::{git, stack};
12
13#[derive(Debug, clap::Args)]
21pub struct Cleanup {
22 #[arg(add = ArgValueCompleter::new(completions::branch_candidates))]
23 branch: Option<String>,
24 #[arg(long, short = 'n', action = ArgAction::SetTrue)]
26 dry_run: bool,
27 #[arg(long, action = ArgAction::SetTrue)]
29 keep_branch: bool,
30}
31
32impl Run for Cleanup {
33 fn run(self) -> Result<()> {
34 cleanup(self.branch.as_deref(), self.dry_run, self.keep_branch)
35 }
36}
37
38pub fn cleanup(branch: Option<&str>, dry_run: bool, keep_branch: bool) -> Result<()> {
39 let branch = branch
40 .map(str::to_owned)
41 .map_or_else(git::current_branch, Ok)?;
42 let branches = stack::branch_and_descendants(&branch)?;
43 let current_branch = git::current_branch()?;
44 let local_branches = git::local_branches()?;
45 let (provider, review_provider) = detect_review_provider()?;
46 let mut cleaned = 0;
47 let mut skipped = 0;
48 let mut retargeted = 0;
49
50 if !dry_run {
52 stack::snapshot("cleanup");
53 }
54
55 let branch_parents = stack::branch_parents(&branches)?;
59 crate::notes::update_stack_notes(review_provider.as_ref(), &branch_parents, dry_run, false)?;
60
61 for branch in branches {
62 retargeted +=
63 recover_deleted_parent(review_provider.as_ref(), &branch, &local_branches, dry_run)?;
64 let Some(review) = review_provider.review_for_branch_including_closed(&branch)? else {
68 anstream::println!(
69 "{}",
70 style::dim(&format!(
71 "skipped {branch}: no {} review found",
72 provider.kind
73 ))
74 );
75 skipped += 1;
76 continue;
77 };
78
79 if review.state != ReviewState::Merged {
80 anstream::println!(
81 "{}",
82 style::dim(&format!(
83 "skipped {branch}: review {} is {}",
84 review.id, review.state
85 ))
86 );
87 skipped += 1;
88 continue;
89 }
90
91 cleanup_merged_branch(review_provider.as_ref(), &branch, dry_run)?;
92 cleanup_branch_deletion(&branch, ¤t_branch, dry_run, !keep_branch)?;
93 cleaned += 1;
94 }
95
96 let retargeted_note = if retargeted > 0 {
97 format!(", {retargeted} retargeted")
98 } else {
99 String::new()
100 };
101 anstream::println!(
102 "{}",
103 style::success(&format!(
104 "cleanup complete: {cleaned} cleaned, {skipped} skipped{retargeted_note}"
105 ))
106 );
107 Ok(())
108}
109
110fn recover_deleted_parent(
115 review_provider: &dyn ReviewProvider,
116 branch: &str,
117 local_branches: &[String],
118 dry_run: bool,
119) -> Result<usize> {
120 let Some(parent) = stack::parent_of(branch)? else {
121 return Ok(0);
122 };
123 if local_branches.contains(&parent) {
124 return Ok(0);
125 }
126
127 let Ok(Some(review)) = owned_review_for_branch(review_provider, &parent) else {
130 return Ok(0);
131 };
132 if review.state != ReviewState::Merged
133 || review.base == *branch
134 || !local_branches.contains(&review.base)
135 {
136 return Ok(0);
137 }
138
139 anstream::println!(
140 "{}: parent {} is gone, but review {} merged into {}",
141 style::branch(branch),
142 style::branch(&parent),
143 review.id,
144 style::branch(&review.base)
145 );
146 anstream::println!(
147 "{} retarget {} -> {}",
148 if dry_run { "would" } else { "will" },
149 style::branch(branch),
150 style::branch(&review.base)
151 );
152 update_child_review_base(review_provider, branch, &review.base, dry_run)?;
153 if !dry_run {
154 stack::set_parent(branch, &review.base)?;
155 }
156 Ok(1)
157}
158
159pub(crate) fn cleanup_merged_branch(
160 review_provider: &dyn ReviewProvider,
161 branch: &str,
162 dry_run: bool,
163) -> Result<()> {
164 let parent = stack::parent_of(branch)?;
165 let descendants = stack::branch_and_descendants(branch)?;
166 let direct_children: Vec<_> = descendants
167 .into_iter()
168 .skip(1)
169 .filter_map(|child| match stack::parent_of(&child) {
170 Ok(Some(child_parent)) if child_parent == branch => Some(Ok(child)),
171 Ok(_) => None,
172 Err(error) => Some(Err(error)),
173 })
174 .collect::<Result<_>>()?;
175
176 for child in direct_children {
177 match parent.as_deref() {
178 Some(parent) => {
179 anstream::println!(
180 "{} retarget {} -> {}",
181 if dry_run { "would" } else { "will" },
182 style::branch(&child),
183 style::branch(parent)
184 );
185 update_child_review_base(review_provider, &child, parent, dry_run)?;
186 if !dry_run {
187 if let Ok(base) = git::merge_base(branch, &child) {
191 stack::set_base(&child, &base)?;
192 }
193 stack::set_parent(&child, parent)?;
194 }
195 }
196 None => {
197 anstream::println!(
198 "{} detach {}",
199 if dry_run { "would" } else { "will" },
200 style::branch(&child)
201 );
202 if !dry_run {
203 stack::unset_parent(&child)?;
204 stack::unset_base(&child)?;
205 }
206 }
207 }
208 }
209
210 anstream::println!(
211 "{} detach {}",
212 if dry_run { "would" } else { "will" },
213 style::branch(branch)
214 );
215 if !dry_run {
216 stack::unset_parent(branch)?;
217 stack::unset_base(branch)?;
218 }
219
220 Ok(())
221}
222
223pub(crate) fn cleanup_branch_deletion(
224 branch: &str,
225 current_branch: &str,
226 dry_run: bool,
227 delete_branch: bool,
228) -> Result<()> {
229 if !delete_branch {
230 return Ok(());
231 }
232
233 if branch == current_branch {
236 anstream::println!(
237 "{}",
238 style::dim(&format!(
239 "kept {branch}: cannot delete the checked out branch"
240 ))
241 );
242 return Ok(());
243 }
244
245 anstream::println!(
246 "{} delete branch {}",
247 if dry_run { "would" } else { "will" },
248 style::branch(branch)
249 );
250 if !dry_run {
251 git::delete_branch(branch)?;
252 }
253
254 Ok(())
255}
256
257fn update_child_review_base(
258 review_provider: &dyn ReviewProvider,
259 child: &str,
260 parent: &str,
261 dry_run: bool,
262) -> Result<()> {
263 let Some(review) = review_provider.review_for_branch(child)? else {
264 return Ok(());
265 };
266
267 if review.state == ReviewState::Merged || review.base == parent {
268 return Ok(());
269 }
270
271 anstream::println!(
272 "{} update review {} -> {} {}",
273 if dry_run { "would" } else { "will" },
274 style::branch(&review.branch),
275 style::branch(parent),
276 style::dim(&format!("({})", review.id))
277 );
278 if !dry_run {
279 let output = review_provider.update_review_base(&review, parent)?;
280 if !output.is_empty() {
281 println!("{output}");
282 }
283 }
284
285 Ok(())
286}