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::{git, stack};
9
10#[derive(Debug, clap::Args)]
13pub struct Cleanup {
14 #[arg(add = ArgValueCompleter::new(completions::branch_candidates))]
15 branch: Option<String>,
16 #[arg(long, action = ArgAction::SetTrue)]
18 dry_run: bool,
19 #[arg(long, action = ArgAction::SetTrue)]
21 keep_branch: bool,
22}
23
24impl Run for Cleanup {
25 fn run(self) -> Result<()> {
26 cleanup(self.branch.as_deref(), self.dry_run, self.keep_branch)
27 }
28}
29
30pub fn cleanup(branch: Option<&str>, dry_run: bool, keep_branch: bool) -> Result<()> {
31 let branch = branch
32 .map(str::to_owned)
33 .map_or_else(git::current_branch, Ok)?;
34 let branches = stack::branch_and_descendants(&branch)?;
35 let current_branch = git::current_branch()?;
36 let local_branches = git::local_branches()?;
37 let provider = detect_provider()?;
38 let review_provider = review_provider(provider.kind);
39 let mut cleaned = 0;
40 let mut skipped = 0;
41 let mut retargeted = 0;
42
43 let branch_parents = stack::branch_parents(&branches)?;
47 crate::notes::update_stack_notes(review_provider.as_ref(), &branch_parents, dry_run)?;
48
49 for branch in branches {
50 retargeted +=
51 recover_deleted_parent(review_provider.as_ref(), &branch, &local_branches, dry_run)?;
52 let Some(review) = review_provider.review_for_branch_including_closed(&branch)? else {
56 println!("skipped {branch}: no {} review found", provider.kind);
57 skipped += 1;
58 continue;
59 };
60
61 if review.state != ReviewState::Merged {
62 println!("skipped {branch}: review {} is {}", review.id, review.state);
63 skipped += 1;
64 continue;
65 }
66
67 cleanup_merged_branch(review_provider.as_ref(), &branch, dry_run)?;
68 cleanup_branch_deletion(&branch, ¤t_branch, dry_run, !keep_branch)?;
69 cleaned += 1;
70 }
71
72 let retargeted_note = if retargeted > 0 {
73 format!(", {retargeted} retargeted")
74 } else {
75 String::new()
76 };
77 println!("cleanup complete: {cleaned} cleaned, {skipped} skipped{retargeted_note}");
78 Ok(())
79}
80
81fn recover_deleted_parent(
86 review_provider: &dyn ReviewProvider,
87 branch: &str,
88 local_branches: &[String],
89 dry_run: bool,
90) -> Result<usize> {
91 let Some(parent) = stack::parent_for_branch(branch)? else {
92 return Ok(0);
93 };
94 if local_branches.contains(&parent) {
95 return Ok(0);
96 }
97
98 let Ok(Some(review)) = review_provider.review_for_branch(&parent) else {
101 return Ok(0);
102 };
103 if review.branch != parent
104 || review.state != ReviewState::Merged
105 || review.base == *branch
106 || !local_branches.contains(&review.base)
107 {
108 return Ok(0);
109 }
110
111 println!(
112 "{branch}: parent {parent} is gone, but review {} merged into {}",
113 review.id, review.base
114 );
115 println!(
116 "{} retarget {branch} -> {}",
117 if dry_run { "would" } else { "will" },
118 review.base
119 );
120 update_child_review_base(review_provider, branch, &review.base, dry_run)?;
121 if !dry_run {
122 stack::set_parent_for_branch(branch, &review.base)?;
123 }
124 Ok(1)
125}
126
127pub(crate) fn cleanup_merged_branch(
128 review_provider: &dyn ReviewProvider,
129 branch: &str,
130 dry_run: bool,
131) -> Result<()> {
132 let parent = stack::parent_for_branch(branch)?;
133 let descendants = stack::branch_and_descendants(branch)?;
134 let direct_children: Vec<_> = descendants
135 .into_iter()
136 .skip(1)
137 .filter_map(|child| match stack::parent_for_branch(&child) {
138 Ok(Some(child_parent)) if child_parent == branch => Some(Ok(child)),
139 Ok(_) => None,
140 Err(error) => Some(Err(error)),
141 })
142 .collect::<Result<_>>()?;
143
144 for child in direct_children {
145 match parent.as_deref() {
146 Some(parent) => {
147 println!(
148 "{} retarget {child} -> {parent}",
149 if dry_run { "would" } else { "will" }
150 );
151 update_child_review_base(review_provider, &child, parent, dry_run)?;
152 if !dry_run {
153 if let Ok(base) = git::merge_base(branch, &child) {
157 stack::set_base_for_branch(&child, &base)?;
158 }
159 stack::set_parent_for_branch(&child, parent)?;
160 }
161 }
162 None => {
163 println!("{} detach {child}", if dry_run { "would" } else { "will" });
164 if !dry_run {
165 stack::unset_parent_for_branch(&child)?;
166 stack::unset_base_for_branch(&child)?;
167 }
168 }
169 }
170 }
171
172 println!("{} detach {branch}", if dry_run { "would" } else { "will" });
173 if !dry_run {
174 stack::unset_parent_for_branch(branch)?;
175 stack::unset_base_for_branch(branch)?;
176 }
177
178 Ok(())
179}
180
181pub(crate) fn cleanup_branch_deletion(
182 branch: &str,
183 current_branch: &str,
184 dry_run: bool,
185 delete_branch: bool,
186) -> Result<()> {
187 if !delete_branch {
188 return Ok(());
189 }
190
191 if branch == current_branch {
194 println!("kept {branch}: cannot delete the checked out branch");
195 return Ok(());
196 }
197
198 println!(
199 "{} delete branch {branch}",
200 if dry_run { "would" } else { "will" }
201 );
202 if !dry_run {
203 git::delete_branch(branch)?;
204 }
205
206 Ok(())
207}
208
209fn update_child_review_base(
210 review_provider: &dyn ReviewProvider,
211 child: &str,
212 parent: &str,
213 dry_run: bool,
214) -> Result<()> {
215 let Some(review) = review_provider.review_for_branch(child)? else {
216 return Ok(());
217 };
218
219 if review.state == ReviewState::Merged || review.base == parent {
220 return Ok(());
221 }
222
223 println!(
224 "{} update review {} -> {} ({})",
225 if dry_run { "would" } else { "will" },
226 review.branch,
227 parent,
228 review.id
229 );
230 if !dry_run {
231 let output = review_provider.update_review_base(&review, parent)?;
232 if !output.is_empty() {
233 println!("{output}");
234 }
235 }
236
237 Ok(())
238}