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