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 for branch in branches {
44 retargeted +=
45 recover_deleted_parent(review_provider.as_ref(), &branch, &local_branches, dry_run)?;
46 let Some(review) = review_provider.review_for_branch_including_closed(&branch)? else {
50 println!("skipped {branch}: no {} review found", provider.kind);
51 skipped += 1;
52 continue;
53 };
54
55 if review.state != ReviewState::Merged {
56 println!("skipped {branch}: review {} is {}", review.id, review.state);
57 skipped += 1;
58 continue;
59 }
60
61 cleanup_merged_branch(review_provider.as_ref(), &branch, dry_run)?;
62 cleanup_branch_deletion(&branch, ¤t_branch, dry_run, !keep_branch)?;
63 cleaned += 1;
64 }
65
66 let retargeted_note = if retargeted > 0 {
67 format!(", {retargeted} retargeted")
68 } else {
69 String::new()
70 };
71 println!("cleanup complete: {cleaned} cleaned, {skipped} skipped{retargeted_note}");
72 Ok(())
73}
74
75fn recover_deleted_parent(
80 review_provider: &dyn ReviewProvider,
81 branch: &str,
82 local_branches: &[String],
83 dry_run: bool,
84) -> Result<usize> {
85 let Some(parent) = stack::parent_for_branch(branch)? else {
86 return Ok(0);
87 };
88 if local_branches.contains(&parent) {
89 return Ok(0);
90 }
91
92 let Ok(Some(review)) = review_provider.review_for_branch(&parent) else {
95 return Ok(0);
96 };
97 if review.branch != parent
98 || review.state != ReviewState::Merged
99 || review.base == *branch
100 || !local_branches.contains(&review.base)
101 {
102 return Ok(0);
103 }
104
105 println!(
106 "{branch}: parent {parent} is gone, but review {} merged into {}",
107 review.id, review.base
108 );
109 println!(
110 "{} retarget {branch} -> {}",
111 if dry_run { "would" } else { "will" },
112 review.base
113 );
114 update_child_review_base(review_provider, branch, &review.base, dry_run)?;
115 if !dry_run {
116 stack::set_parent_for_branch(branch, &review.base)?;
117 }
118 Ok(1)
119}
120
121pub(crate) fn cleanup_merged_branch(
122 review_provider: &dyn ReviewProvider,
123 branch: &str,
124 dry_run: bool,
125) -> Result<()> {
126 let parent = stack::parent_for_branch(branch)?;
127 let descendants = stack::branch_and_descendants(branch)?;
128 let direct_children: Vec<_> = descendants
129 .into_iter()
130 .skip(1)
131 .filter_map(|child| match stack::parent_for_branch(&child) {
132 Ok(Some(child_parent)) if child_parent == branch => Some(Ok(child)),
133 Ok(_) => None,
134 Err(error) => Some(Err(error)),
135 })
136 .collect::<Result<_>>()?;
137
138 for child in direct_children {
139 match parent.as_deref() {
140 Some(parent) => {
141 println!(
142 "{} retarget {child} -> {parent}",
143 if dry_run { "would" } else { "will" }
144 );
145 update_child_review_base(review_provider, &child, parent, dry_run)?;
146 if !dry_run {
147 if let Ok(base) = git::merge_base(branch, &child) {
151 stack::set_base_for_branch(&child, &base)?;
152 }
153 stack::set_parent_for_branch(&child, parent)?;
154 }
155 }
156 None => {
157 println!("{} detach {child}", if dry_run { "would" } else { "will" });
158 if !dry_run {
159 stack::unset_parent_for_branch(&child)?;
160 stack::unset_base_for_branch(&child)?;
161 }
162 }
163 }
164 }
165
166 println!("{} detach {branch}", if dry_run { "would" } else { "will" });
167 if !dry_run {
168 stack::unset_parent_for_branch(branch)?;
169 stack::unset_base_for_branch(branch)?;
170 }
171
172 Ok(())
173}
174
175pub(crate) fn cleanup_branch_deletion(
176 branch: &str,
177 current_branch: &str,
178 dry_run: bool,
179 delete_branch: bool,
180) -> Result<()> {
181 if !delete_branch {
182 return Ok(());
183 }
184
185 if branch == current_branch {
188 println!("kept {branch}: cannot delete the checked out branch");
189 return Ok(());
190 }
191
192 println!(
193 "{} delete branch {branch}",
194 if dry_run { "would" } else { "will" }
195 );
196 if !dry_run {
197 git::delete_branch(branch)?;
198 }
199
200 Ok(())
201}
202
203fn update_child_review_base(
204 review_provider: &dyn ReviewProvider,
205 child: &str,
206 parent: &str,
207 dry_run: bool,
208) -> Result<()> {
209 let Some(review) = review_provider.review_for_branch(child)? else {
210 return Ok(());
211 };
212
213 if review.state == ReviewState::Merged || review.base == parent {
214 return Ok(());
215 }
216
217 println!(
218 "{} update review {} -> {} ({})",
219 if dry_run { "would" } else { "will" },
220 review.branch,
221 parent,
222 review.id
223 );
224 if !dry_run {
225 let output = review_provider.update_review_base(&review, parent)?;
226 if !output.is_empty() {
227 println!("{output}");
228 }
229 }
230
231 Ok(())
232}