Skip to main content

git_stk/commands/
cleanup.rs

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/// Clean up local metadata for merged review requests and delete their
14/// branches.
15///
16/// Unlike `merge`, this does not prompt: it only ever deletes branches whose
17/// review is *merged*, so the work is already in the trunk and the ref is
18/// recoverable from the reflog - the same reason `sync` deletes merged
19/// branches unprompted. `--dry-run` previews and `--keep-branch` retains them.
20#[derive(Debug, clap::Args)]
21pub struct Cleanup {
22    #[arg(add = ArgValueCompleter::new(completions::branch_candidates))]
23    branch: Option<String>,
24    /// Print what would change without updating local metadata.
25    #[arg(long, short = 'n', action = ArgAction::SetTrue)]
26    dry_run: bool,
27    /// Keep cleaned merged branches instead of deleting them.
28    #[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    // Snapshot before any branch is retargeted or deleted.
51    if !dry_run {
52        stack::snapshot("cleanup");
53    }
54
55    // Refresh the stack overview ledger while the merged branches and their
56    // reviews are still resolvable, so their entries get restyled rather
57    // than dropped - mirroring sync.
58    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        // Closed-inclusive so a review closed without merging gets a
65        // truthful skip instead of "no review found". Only merged reviews
66        // are ever cleaned: a closed review's work is not in the trunk.
67        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, &current_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
110/// A merged parent deleted remotely (and pruned locally) leaves `branch`
111/// pointing at nothing, but the merged review still remembers its base.
112/// Retarget past the gap; the recorded fork point stays valid because it
113/// lives in the branch's own history. Returns how many branches moved.
114fn 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    // Provider lookups go by ref name, so the review outlives the branch.
128    // Best effort: anything unresolved stays for `git stk repair`.
129    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                    // Record the fork point off the merged branch before
188                    // retargeting, so the next restack replays only the
189                    // child's own commits even after a squash merge.
190                    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    // The checked out branch cannot be deleted; keep it and let the user
234    // switch away instead of failing the rest of the cleanup.
235    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}