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    /// Branch to clean up (defaults to the current branch).
23    #[arg(add = ArgValueCompleter::new(completions::branch_candidates))]
24    branch: Option<String>,
25    /// Print what would change without updating local metadata.
26    #[arg(long, short = 'n', action = ArgAction::SetTrue)]
27    dry_run: bool,
28    /// Keep cleaned merged branches instead of deleting them.
29    #[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    // Snapshot before any branch is retargeted or deleted.
52    if !dry_run {
53        stack::snapshot("cleanup");
54    }
55
56    // Refresh the stack overview ledger while the merged branches and their
57    // reviews are still resolvable, so their entries get restyled rather
58    // than dropped - mirroring sync.
59    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        // Closed-inclusive so a review closed without merging gets a
66        // truthful skip instead of "no review found". Only merged reviews
67        // are ever cleaned: a closed review's work is not in the trunk.
68        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, &current_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
111/// A merged parent deleted remotely (and pruned locally) leaves `branch`
112/// pointing at nothing, but the merged review still remembers its base.
113/// Retarget past the gap; the recorded fork point stays valid because it
114/// lives in the branch's own history. Returns how many branches moved.
115fn 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    // Provider lookups go by ref name, so the review outlives the branch.
129    // Best effort: anything unresolved stays for `git stk repair`.
130    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                    // Record the fork point off the merged branch before
189                    // retargeting, so the next restack replays only the
190                    // child's own commits even after a squash merge.
191                    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    // The checked out branch cannot be deleted; keep it and let the user
235    // switch away instead of failing the rest of the cleanup.
236    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}