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