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    BaseGap, 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 finished 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        // The base a stack sits on is not ours to finish: a merged release PR
91        // would otherwise detach the layers above it and delete the branch -
92        // the destructive half of the same hazard `sync` guards against.
93        if stack::is_floor(&branch)? {
94            anstream::println!(
95                "{}",
96                style::dim(&format!("skipped {branch}: this stack's base"))
97            );
98            skipped += 1;
99            continue;
100        }
101
102        retargeted += recover_deleted_parent(
103            review_provider.as_ref(),
104            &branch,
105            &local_branches,
106            clean_closed,
107            dry_run,
108        )?;
109        // Closed-inclusive so a review closed without merging gets a truthful
110        // skip instead of "no review found" - and so `stk.cleanClosed` can act
111        // on it.
112        let Some(review) = review_provider.review_for_branch_including_closed(&branch)? else {
113            anstream::println!(
114                "{}",
115                style::dim(&format!(
116                    "skipped {branch}: no {} review found",
117                    provider.kind
118                ))
119            );
120            skipped += 1;
121            continue;
122        };
123
124        let Some(landing) = landing_for(&review.state, clean_closed) else {
125            anstream::println!(
126                "{}",
127                style::dim(&format!(
128                    "skipped {branch}: review {} is {}",
129                    review.id, review.state
130                ))
131            );
132            skipped += 1;
133            continue;
134        };
135
136        // `--keep-branch` keeps the ref on purpose, so the deletion guards do
137        // not apply: clean the metadata and stop there. Otherwise a ref that
138        // cannot go yet keeps its metadata too, and stays in the stack.
139        if !keep_branch && let Some(reason) = deletion_blocker(&branch, &current_branch)? {
140            report_kept(&branch, &reason);
141            kept += 1;
142            continue;
143        }
144
145        cleanup_finished_branch(review_provider.as_ref(), &branch, landing, dry_run)?;
146        if !keep_branch {
147            cleanup_branch_deletion(&branch, landing, dry_run)?;
148        }
149        cleaned += 1;
150    }
151
152    // Only mention the extras when there are any, so the common line stays
153    // short.
154    let kept_note = if kept > 0 {
155        format!(", {kept} kept")
156    } else {
157        String::new()
158    };
159    let retargeted_note = if retargeted > 0 {
160        format!(", {retargeted} retargeted")
161    } else {
162        String::new()
163    };
164    anstream::println!(
165        "{}",
166        style::success(&format!(
167            "cleanup complete: {cleaned} cleaned, {skipped} skipped{kept_note}{retargeted_note}"
168        ))
169    );
170    Ok(())
171}
172
173/// A finished parent deleted remotely (and pruned locally) leaves `branch`
174/// pointing at nothing, but the review still remembers its base. Retarget past
175/// the gap. Returns how many branches moved.
176fn recover_deleted_parent(
177    review_provider: &dyn ReviewProvider,
178    branch: &str,
179    local_branches: &[String],
180    clean_closed: bool,
181    dry_run: bool,
182) -> Result<usize> {
183    let Some(parent) = stack::parent_of(branch)? else {
184        return Ok(0);
185    };
186    if local_branches.contains(&parent) {
187        return Ok(0);
188    }
189
190    // Provider lookups go by ref name, so the review outlives the branch.
191    // Best effort: anything unresolved stays for `git stk repair`.
192    let Ok(Some(review)) = owned_review_for_branch(review_provider, &parent) else {
193        return Ok(0);
194    };
195    let Some(landing) = landing_for(&review.state, clean_closed) else {
196        return Ok(0);
197    };
198    // A base that landed is still not a parent we recover past.
199    if stack::is_floor(&parent)? {
200        return Ok(0);
201    }
202    if review.base == *branch || !local_branches.contains(&review.base) {
203        return Ok(0);
204    }
205
206    match landing {
207        Landing::Merged => anstream::println!(
208            "{}: parent {} is gone, but review {} merged into {}",
209            style::branch(branch),
210            style::branch(&parent),
211            review.id,
212            style::branch(&review.base)
213        ),
214        Landing::Closed => anstream::println!(
215            "{}: parent {} is gone; review {} was closed against {}",
216            style::branch(branch),
217            style::branch(&parent),
218            review.id,
219            style::branch(&review.base)
220        ),
221    }
222    anstream::println!(
223        "{} retarget {} -> {}",
224        if dry_run { "would" } else { "will" },
225        style::branch(branch),
226        style::branch(&review.base)
227    );
228    update_child_review_base(review_provider, branch, &review.base, dry_run)?;
229    if !dry_run {
230        // A merged parent's fork point stays valid: it lives in this branch's
231        // own history and its commits are upstream. A closed parent's commits
232        // survive only here, so a fork point recorded off it would make the
233        // restack drop them.
234        if landing == Landing::Closed {
235            stack::unset_base(branch)?;
236        }
237        stack::set_parent(branch, &review.base)?;
238    }
239    Ok(1)
240}
241
242/// Retarget a finished branch's children onto its parent, then detach the
243/// branch itself. The children's recorded fork points depend on `landing`: see
244/// [`Landing`].
245pub(crate) fn cleanup_finished_branch(
246    review_provider: &dyn ReviewProvider,
247    branch: &str,
248    landing: Landing,
249    dry_run: bool,
250) -> Result<()> {
251    let parent = stack::parent_of(branch)?;
252    let descendants = stack::branch_and_descendants(branch)?;
253    let direct_children: Vec<_> = descendants
254        .into_iter()
255        .skip(1)
256        .filter_map(|child| match stack::parent_of(&child) {
257            Ok(Some(child_parent)) if child_parent == branch => Some(Ok(child)),
258            Ok(_) => None,
259            Err(error) => Some(Err(error)),
260        })
261        .collect::<Result<_>>()?;
262
263    for child in direct_children {
264        match parent.as_deref() {
265            Some(parent) => {
266                anstream::println!(
267                    "{} retarget {} -> {}",
268                    if dry_run { "would" } else { "will" },
269                    style::branch(&child),
270                    style::branch(parent)
271                );
272                update_child_review_base(review_provider, &child, parent, dry_run)?;
273                if !dry_run {
274                    match landing {
275                        // Record the fork point off the merged branch before
276                        // retargeting, so the next restack replays only the
277                        // child's own commits even after a squash merge.
278                        Landing::Merged => {
279                            if let Ok(base) = git::merge_base(branch, &child) {
280                                stack::set_base(&child, &base)?;
281                            }
282                        }
283                        // A closed branch's commits landed nowhere, and the
284                        // child was written on top of them: drop the fork
285                        // point so the restack replays everything the child
286                        // has that its new parent lacks, closed commits
287                        // included.
288                        Landing::Closed => stack::unset_base(&child)?,
289                    }
290                    stack::set_parent(&child, parent)?;
291                }
292            }
293            None => {
294                anstream::println!(
295                    "{} detach {}",
296                    if dry_run { "would" } else { "will" },
297                    style::branch(&child)
298                );
299                if !dry_run {
300                    stack::unset_parent(&child)?;
301                    stack::unset_base(&child)?;
302                }
303            }
304        }
305    }
306    anstream::println!(
307        "{} detach {}",
308        if dry_run { "would" } else { "will" },
309        style::branch(branch)
310    );
311    if !dry_run {
312        stack::unset_parent(branch)?;
313        stack::unset_base(branch)?;
314    }
315
316    Ok(())
317}
318
319/// Why `branch`'s ref cannot go yet, or `None` when it can. Asked *before*
320/// anything is written: a branch that has to stay keeps its stack metadata too,
321/// so it stays in the stack for a later cleanup rather than being silently
322/// unstacked.
323pub(crate) fn deletion_blocker(branch: &str, current_branch: &str) -> Result<Option<String>> {
324    // The checked out branch cannot be deleted; keep it and let the user
325    // switch away instead of failing the rest of the cleanup.
326    if branch == current_branch {
327        return Ok(Some("cannot delete the checked out branch".to_owned()));
328    }
329
330    // Nor can a branch another worktree holds - but a worktree git-stk created
331    // for this branch is ours to remove.
332    let Some(path) = git::worktree_holding(branch)? else {
333        return Ok(None);
334    };
335    if !stack::owned_worktree(branch).is_some_and(|owned| git::same_path(&owned, &path)) {
336        // The user's own worktree. Naming where it lives keeps the rest of the
337        // cleanup running - a landed stack should not stop halfway because one
338        // branch has a worktree parked on it.
339        return Ok(Some(format!(
340            "checked out in the worktree at {}",
341            git::display_path(&path)
342        )));
343    }
344    // Ours, but not ours to throw away: uncommitted work in it is not covered
345    // by any snapshot.
346    if git::worktree_has_changes(&path) {
347        return Ok(Some(format!(
348            "its worktree at {} has uncommitted changes",
349            git::display_path(&path)
350        )));
351    }
352    Ok(None)
353}
354
355/// Report a finished branch whose ref stays for now, and why.
356pub(crate) fn report_kept(branch: &str, reason: &str) {
357    anstream::println!(
358        "{}",
359        style::dim(&format!(
360            "kept {branch}: {reason} - still stacked, so a later cleanup can finish it"
361        ))
362    );
363}
364
365/// Delete `branch`, removing the worktree git-stk made for it first: git refuses
366/// to delete a branch a worktree still holds. Call [`deletion_blocker`] first -
367/// this assumes the ref is free to go.
368pub(crate) fn cleanup_branch_deletion(branch: &str, landing: Landing, dry_run: bool) -> Result<()> {
369    if let Some(path) = stack::owned_worktree(branch).filter(|owned| {
370        git::worktree_holding(branch)
371            .ok()
372            .flatten()
373            .is_some_and(|held| git::same_path(owned, &held))
374    }) {
375        anstream::println!(
376            "{} remove worktree {}",
377            if dry_run { "would" } else { "will" },
378            git::display_path(&path)
379        );
380        if !dry_run {
381            git::worktree_remove(&path)?;
382            stack::unset_owned_worktree(branch)?;
383        }
384    }
385
386    // A closed branch's commits are in no other branch, so say so on the way
387    // out and name the way back: the snapshot `undo` restores is the only
388    // handle most people will have.
389    let caveat = match landing {
390        Landing::Merged => String::new(),
391        Landing::Closed => style::dim(" (closed, not merged - `git stk undo` restores it)"),
392    };
393    anstream::println!(
394        "{} delete branch {}{caveat}",
395        if dry_run { "would" } else { "will" },
396        style::branch(branch)
397    );
398    if !dry_run {
399        git::delete_branch(branch)?;
400    }
401
402    Ok(())
403}
404
405fn update_child_review_base(
406    review_provider: &dyn ReviewProvider,
407    child: &str,
408    parent: &str,
409    dry_run: bool,
410) -> Result<()> {
411    let Some(review) = review_provider.review_for_branch(child)? else {
412        return Ok(());
413    };
414
415    if review.state == ReviewState::Merged || review.base == parent {
416        return Ok(());
417    }
418
419    // Asked before the announcement, not after it: a base git-stk will not
420    // touch is a decision already made, and saying "would update" first
421    // describes it backwards. On a dry run the old order printed only the
422    // first half.
423    match review_provider.base_gap(&review, parent)? {
424        Some(BaseGap::Platform) => {
425            anstream::println!(
426                "{}",
427                style::dim(&format!(
428                    "{} is in a stack; the platform retargets it as the stack lands",
429                    review.id
430                ))
431            );
432            return Ok(());
433        }
434        // Loud rather than dim for both of these: the branch this review
435        // targets is the one being cleaned up, so leaving it unsaid ends with
436        // the review pointing at a deleted branch.
437        Some(BaseGap::Sync) => {
438            anstream::println!(
439                "{}",
440                style::warn(&format!(
441                    "{} already targets {} - the platform moved it when {parent} landed; \
442                     run `git stk sync` to catch the local stack up",
443                    review.id, review.base
444                ))
445            );
446            return Ok(());
447        }
448        Some(BaseGap::Neither) => {
449            anstream::println!(
450                "{}",
451                style::warn(&format!(
452                    "{} still targets {} and its stack will not move it to {parent} - the \
453                     platform refuses a change by hand too; run `git stk unstack`, \
454                     then `git stk submit`",
455                    review.id, review.base
456                ))
457            );
458            return Ok(());
459        }
460        None => {}
461    }
462
463    anstream::println!(
464        "{} update review {} -> {} {}",
465        if dry_run { "would" } else { "will" },
466        style::branch(&review.branch),
467        style::branch(parent),
468        style::dim(&format!("({})", review.id))
469    );
470    if !dry_run {
471        let output = review_provider.update_review_base(&review, parent)?;
472        if !output.is_empty() {
473            println!("{output}");
474        }
475    }
476
477    Ok(())
478}