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::settings;
11use crate::style;
12use crate::{git, stack};
13
14/// Clean up local metadata for finished review requests and delete their
15/// branches.
16///
17/// Unlike `merge`, this does not prompt: a merged branch's work is already in
18/// the trunk and the ref is recoverable from the reflog (and `git stk undo`) -
19/// the same reason `sync` deletes merged branches unprompted. Under
20/// `stk.cleanClosed` it also cleans up branches whose review was closed without
21/// merging; those commits are upstream nowhere, so the deletion says as much
22/// and their children keep them. `--dry-run` previews and `--keep-branch`
23/// retains them.
24#[derive(Debug, clap::Args)]
25pub struct Cleanup {
26    /// Branch to clean up (defaults to the current branch).
27    #[arg(add = ArgValueCompleter::new(completions::branch_candidates))]
28    branch: Option<String>,
29    /// Print what would change without updating local metadata.
30    #[arg(long, short = 'n', action = ArgAction::SetTrue)]
31    dry_run: bool,
32    /// Keep cleaned branches instead of deleting them.
33    #[arg(long, action = ArgAction::SetTrue)]
34    keep_branch: bool,
35}
36
37/// Whether the branch being cleaned up reached the trunk. A merged branch's
38/// commits are upstream, so its children's fork points can be pinned past
39/// them; a closed branch's commits live nowhere else, so its children have to
40/// keep them.
41#[derive(Debug, Clone, Copy, Eq, PartialEq)]
42pub(crate) enum Landing {
43    Merged,
44    Closed,
45}
46
47/// Which reviews `cleanup` and `sync` act on: merged always, closed only under
48/// `stk.cleanClosed` - for some workflows closing a review means the branch is
49/// done too. `None` is a review to leave alone.
50pub(crate) fn landing_for(state: &ReviewState, clean_closed: bool) -> Option<Landing> {
51    match state {
52        ReviewState::Merged => Some(Landing::Merged),
53        ReviewState::Closed if clean_closed => Some(Landing::Closed),
54        _ => None,
55    }
56}
57
58impl Run for Cleanup {
59    fn run(self) -> Result<()> {
60        cleanup(self.branch.as_deref(), self.dry_run, self.keep_branch)
61    }
62}
63
64pub fn cleanup(branch: Option<&str>, dry_run: bool, keep_branch: bool) -> Result<()> {
65    let branch = branch
66        .map(str::to_owned)
67        .map_or_else(git::current_branch, Ok)?;
68    let branches = stack::branch_and_descendants(&branch)?;
69    let current_branch = git::current_branch()?;
70    let local_branches = git::local_branches()?;
71    let (provider, review_provider) = detect_review_provider()?;
72    let clean_closed = settings::bool_setting(settings::CLEAN_CLOSED_KEY)?;
73    let mut cleaned = 0;
74    let mut skipped = 0;
75    let mut kept = 0;
76    let mut retargeted = 0;
77
78    // Snapshot before any branch is retargeted or deleted.
79    if !dry_run {
80        stack::snapshot("cleanup");
81    }
82
83    // Refresh the stack overview ledger while the merged branches and their
84    // reviews are still resolvable, so their entries get restyled rather
85    // than dropped - mirroring sync.
86    let branch_parents = stack::branch_parents(&branches)?;
87    crate::notes::update_stack_notes(review_provider.as_ref(), &branch_parents, dry_run, false)?;
88
89    for branch in branches {
90        retargeted += recover_deleted_parent(
91            review_provider.as_ref(),
92            &branch,
93            &local_branches,
94            clean_closed,
95            dry_run,
96        )?;
97        // Closed-inclusive so a review closed without merging gets a truthful
98        // skip instead of "no review found" - and so `stk.cleanClosed` can act
99        // on it.
100        let Some(review) = review_provider.review_for_branch_including_closed(&branch)? else {
101            anstream::println!(
102                "{}",
103                style::dim(&format!(
104                    "skipped {branch}: no {} review found",
105                    provider.kind
106                ))
107            );
108            skipped += 1;
109            continue;
110        };
111
112        let Some(landing) = landing_for(&review.state, clean_closed) else {
113            anstream::println!(
114                "{}",
115                style::dim(&format!(
116                    "skipped {branch}: review {} is {}",
117                    review.id, review.state
118                ))
119            );
120            skipped += 1;
121            continue;
122        };
123
124        // `--keep-branch` keeps the ref on purpose, so the deletion guards do
125        // not apply: clean the metadata and stop there. Otherwise a ref that
126        // cannot go yet keeps its metadata too, and stays in the stack.
127        if !keep_branch && let Some(reason) = deletion_blocker(&branch, &current_branch)? {
128            report_kept(&branch, &reason);
129            kept += 1;
130            continue;
131        }
132
133        cleanup_finished_branch(review_provider.as_ref(), &branch, landing, dry_run)?;
134        if !keep_branch {
135            cleanup_branch_deletion(&branch, landing, dry_run)?;
136        }
137        cleaned += 1;
138    }
139
140    // Only mention the extras when there are any, so the common line stays
141    // short.
142    let kept_note = if kept > 0 {
143        format!(", {kept} kept")
144    } else {
145        String::new()
146    };
147    let retargeted_note = if retargeted > 0 {
148        format!(", {retargeted} retargeted")
149    } else {
150        String::new()
151    };
152    anstream::println!(
153        "{}",
154        style::success(&format!(
155            "cleanup complete: {cleaned} cleaned, {skipped} skipped{kept_note}{retargeted_note}"
156        ))
157    );
158    Ok(())
159}
160
161/// A finished parent deleted remotely (and pruned locally) leaves `branch`
162/// pointing at nothing, but the review still remembers its base. Retarget past
163/// the gap. Returns how many branches moved.
164fn recover_deleted_parent(
165    review_provider: &dyn ReviewProvider,
166    branch: &str,
167    local_branches: &[String],
168    clean_closed: bool,
169    dry_run: bool,
170) -> Result<usize> {
171    let Some(parent) = stack::parent_of(branch)? else {
172        return Ok(0);
173    };
174    if local_branches.contains(&parent) {
175        return Ok(0);
176    }
177
178    // Provider lookups go by ref name, so the review outlives the branch.
179    // Best effort: anything unresolved stays for `git stk repair`.
180    let Ok(Some(review)) = owned_review_for_branch(review_provider, &parent) else {
181        return Ok(0);
182    };
183    let Some(landing) = landing_for(&review.state, clean_closed) else {
184        return Ok(0);
185    };
186    if review.base == *branch || !local_branches.contains(&review.base) {
187        return Ok(0);
188    }
189
190    match landing {
191        Landing::Merged => anstream::println!(
192            "{}: parent {} is gone, but review {} merged into {}",
193            style::branch(branch),
194            style::branch(&parent),
195            review.id,
196            style::branch(&review.base)
197        ),
198        Landing::Closed => anstream::println!(
199            "{}: parent {} is gone; review {} was closed against {}",
200            style::branch(branch),
201            style::branch(&parent),
202            review.id,
203            style::branch(&review.base)
204        ),
205    }
206    anstream::println!(
207        "{} retarget {} -> {}",
208        if dry_run { "would" } else { "will" },
209        style::branch(branch),
210        style::branch(&review.base)
211    );
212    update_child_review_base(review_provider, branch, &review.base, dry_run)?;
213    if !dry_run {
214        // A merged parent's fork point stays valid: it lives in this branch's
215        // own history and its commits are upstream. A closed parent's commits
216        // survive only here, so a fork point recorded off it would make the
217        // restack drop them.
218        if landing == Landing::Closed {
219            stack::unset_base(branch)?;
220        }
221        stack::set_parent(branch, &review.base)?;
222    }
223    Ok(1)
224}
225
226/// Retarget a finished branch's children onto its parent, then detach the
227/// branch itself. The children's recorded fork points depend on `landing`: see
228/// [`Landing`].
229pub(crate) fn cleanup_finished_branch(
230    review_provider: &dyn ReviewProvider,
231    branch: &str,
232    landing: Landing,
233    dry_run: bool,
234) -> Result<()> {
235    let parent = stack::parent_of(branch)?;
236    let descendants = stack::branch_and_descendants(branch)?;
237    let direct_children: Vec<_> = descendants
238        .into_iter()
239        .skip(1)
240        .filter_map(|child| match stack::parent_of(&child) {
241            Ok(Some(child_parent)) if child_parent == branch => Some(Ok(child)),
242            Ok(_) => None,
243            Err(error) => Some(Err(error)),
244        })
245        .collect::<Result<_>>()?;
246
247    for child in direct_children {
248        match parent.as_deref() {
249            Some(parent) => {
250                anstream::println!(
251                    "{} retarget {} -> {}",
252                    if dry_run { "would" } else { "will" },
253                    style::branch(&child),
254                    style::branch(parent)
255                );
256                update_child_review_base(review_provider, &child, parent, dry_run)?;
257                if !dry_run {
258                    match landing {
259                        // Record the fork point off the merged branch before
260                        // retargeting, so the next restack replays only the
261                        // child's own commits even after a squash merge.
262                        Landing::Merged => {
263                            if let Ok(base) = git::merge_base(branch, &child) {
264                                stack::set_base(&child, &base)?;
265                            }
266                        }
267                        // A closed branch's commits landed nowhere, and the
268                        // child was written on top of them: drop the fork
269                        // point so the restack replays everything the child
270                        // has that its new parent lacks, closed commits
271                        // included.
272                        Landing::Closed => stack::unset_base(&child)?,
273                    }
274                    stack::set_parent(&child, parent)?;
275                }
276            }
277            None => {
278                anstream::println!(
279                    "{} detach {}",
280                    if dry_run { "would" } else { "will" },
281                    style::branch(&child)
282                );
283                if !dry_run {
284                    stack::unset_parent(&child)?;
285                    stack::unset_base(&child)?;
286                }
287            }
288        }
289    }
290    anstream::println!(
291        "{} detach {}",
292        if dry_run { "would" } else { "will" },
293        style::branch(branch)
294    );
295    if !dry_run {
296        stack::unset_parent(branch)?;
297        stack::unset_base(branch)?;
298    }
299
300    Ok(())
301}
302
303/// Why `branch`'s ref cannot go yet, or `None` when it can. Asked *before*
304/// anything is written: a branch that has to stay keeps its stack metadata too,
305/// so it stays in the stack for a later cleanup rather than being silently
306/// unstacked.
307pub(crate) fn deletion_blocker(branch: &str, current_branch: &str) -> Result<Option<String>> {
308    // The checked out branch cannot be deleted; keep it and let the user
309    // switch away instead of failing the rest of the cleanup.
310    if branch == current_branch {
311        return Ok(Some("cannot delete the checked out branch".to_owned()));
312    }
313
314    // Nor can a branch another worktree holds - but a worktree git-stk created
315    // for this branch is ours to remove.
316    let Some(path) = git::worktree_holding(branch)? else {
317        return Ok(None);
318    };
319    if !stack::owned_worktree(branch).is_some_and(|owned| git::same_path(&owned, &path)) {
320        // The user's own worktree. Naming where it lives keeps the rest of the
321        // cleanup running - a landed stack should not stop halfway because one
322        // branch has a worktree parked on it.
323        return Ok(Some(format!(
324            "checked out in the worktree at {}",
325            git::display_path(&path)
326        )));
327    }
328    // Ours, but not ours to throw away: uncommitted work in it is not covered
329    // by any snapshot.
330    if git::worktree_has_changes(&path) {
331        return Ok(Some(format!(
332            "its worktree at {} has uncommitted changes",
333            git::display_path(&path)
334        )));
335    }
336    Ok(None)
337}
338
339/// Report a finished branch whose ref stays for now, and why.
340pub(crate) fn report_kept(branch: &str, reason: &str) {
341    anstream::println!(
342        "{}",
343        style::dim(&format!(
344            "kept {branch}: {reason} - still stacked, so a later cleanup can finish it"
345        ))
346    );
347}
348
349/// Delete `branch`, removing the worktree git-stk made for it first: git refuses
350/// to delete a branch a worktree still holds. Call [`deletion_blocker`] first -
351/// this assumes the ref is free to go.
352pub(crate) fn cleanup_branch_deletion(branch: &str, landing: Landing, dry_run: bool) -> Result<()> {
353    if let Some(path) = stack::owned_worktree(branch).filter(|owned| {
354        git::worktree_holding(branch)
355            .ok()
356            .flatten()
357            .is_some_and(|held| git::same_path(owned, &held))
358    }) {
359        anstream::println!(
360            "{} remove worktree {}",
361            if dry_run { "would" } else { "will" },
362            git::display_path(&path)
363        );
364        if !dry_run {
365            git::worktree_remove(&path)?;
366            stack::unset_owned_worktree(branch)?;
367        }
368    }
369
370    // A closed branch's commits are in no other branch, so say so on the way
371    // out and name the way back: the snapshot `undo` restores is the only
372    // handle most people will have.
373    let caveat = match landing {
374        Landing::Merged => String::new(),
375        Landing::Closed => style::dim(" (closed, not merged - `git stk undo` restores it)"),
376    };
377    anstream::println!(
378        "{} delete branch {}{caveat}",
379        if dry_run { "would" } else { "will" },
380        style::branch(branch)
381    );
382    if !dry_run {
383        git::delete_branch(branch)?;
384    }
385
386    Ok(())
387}
388
389fn update_child_review_base(
390    review_provider: &dyn ReviewProvider,
391    child: &str,
392    parent: &str,
393    dry_run: bool,
394) -> Result<()> {
395    let Some(review) = review_provider.review_for_branch(child)? else {
396        return Ok(());
397    };
398
399    if review.state == ReviewState::Merged || review.base == parent {
400        return Ok(());
401    }
402
403    anstream::println!(
404        "{} update review {} -> {} {}",
405        if dry_run { "would" } else { "will" },
406        style::branch(&review.branch),
407        style::branch(parent),
408        style::dim(&format!("({})", review.id))
409    );
410    if !dry_run {
411        let output = review_provider.update_review_base(&review, parent)?;
412        if !output.is_empty() {
413            println!("{output}");
414        }
415    }
416
417    Ok(())
418}