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 retargeted = 0;
76
77    // Snapshot before any branch is retargeted or deleted.
78    if !dry_run {
79        stack::snapshot("cleanup");
80    }
81
82    // Refresh the stack overview ledger while the merged branches and their
83    // reviews are still resolvable, so their entries get restyled rather
84    // than dropped - mirroring sync.
85    let branch_parents = stack::branch_parents(&branches)?;
86    crate::notes::update_stack_notes(review_provider.as_ref(), &branch_parents, dry_run, false)?;
87
88    for branch in branches {
89        retargeted += recover_deleted_parent(
90            review_provider.as_ref(),
91            &branch,
92            &local_branches,
93            clean_closed,
94            dry_run,
95        )?;
96        // Closed-inclusive so a review closed without merging gets a truthful
97        // skip instead of "no review found" - and so `stk.cleanClosed` can act
98        // on it.
99        let Some(review) = review_provider.review_for_branch_including_closed(&branch)? else {
100            anstream::println!(
101                "{}",
102                style::dim(&format!(
103                    "skipped {branch}: no {} review found",
104                    provider.kind
105                ))
106            );
107            skipped += 1;
108            continue;
109        };
110
111        let Some(landing) = landing_for(&review.state, clean_closed) else {
112            anstream::println!(
113                "{}",
114                style::dim(&format!(
115                    "skipped {branch}: review {} is {}",
116                    review.id, review.state
117                ))
118            );
119            skipped += 1;
120            continue;
121        };
122
123        cleanup_finished_branch(review_provider.as_ref(), &branch, landing, dry_run)?;
124        cleanup_branch_deletion(&branch, &current_branch, landing, dry_run, !keep_branch)?;
125        cleaned += 1;
126    }
127
128    let retargeted_note = if retargeted > 0 {
129        format!(", {retargeted} retargeted")
130    } else {
131        String::new()
132    };
133    anstream::println!(
134        "{}",
135        style::success(&format!(
136            "cleanup complete: {cleaned} cleaned, {skipped} skipped{retargeted_note}"
137        ))
138    );
139    Ok(())
140}
141
142/// A finished parent deleted remotely (and pruned locally) leaves `branch`
143/// pointing at nothing, but the review still remembers its base. Retarget past
144/// the gap. Returns how many branches moved.
145fn recover_deleted_parent(
146    review_provider: &dyn ReviewProvider,
147    branch: &str,
148    local_branches: &[String],
149    clean_closed: bool,
150    dry_run: bool,
151) -> Result<usize> {
152    let Some(parent) = stack::parent_of(branch)? else {
153        return Ok(0);
154    };
155    if local_branches.contains(&parent) {
156        return Ok(0);
157    }
158
159    // Provider lookups go by ref name, so the review outlives the branch.
160    // Best effort: anything unresolved stays for `git stk repair`.
161    let Ok(Some(review)) = owned_review_for_branch(review_provider, &parent) else {
162        return Ok(0);
163    };
164    let Some(landing) = landing_for(&review.state, clean_closed) else {
165        return Ok(0);
166    };
167    if review.base == *branch || !local_branches.contains(&review.base) {
168        return Ok(0);
169    }
170
171    match landing {
172        Landing::Merged => anstream::println!(
173            "{}: parent {} is gone, but review {} merged into {}",
174            style::branch(branch),
175            style::branch(&parent),
176            review.id,
177            style::branch(&review.base)
178        ),
179        Landing::Closed => anstream::println!(
180            "{}: parent {} is gone; review {} was closed against {}",
181            style::branch(branch),
182            style::branch(&parent),
183            review.id,
184            style::branch(&review.base)
185        ),
186    }
187    anstream::println!(
188        "{} retarget {} -> {}",
189        if dry_run { "would" } else { "will" },
190        style::branch(branch),
191        style::branch(&review.base)
192    );
193    update_child_review_base(review_provider, branch, &review.base, dry_run)?;
194    if !dry_run {
195        // A merged parent's fork point stays valid: it lives in this branch's
196        // own history and its commits are upstream. A closed parent's commits
197        // survive only here, so a fork point recorded off it would make the
198        // restack drop them.
199        if landing == Landing::Closed {
200            stack::unset_base(branch)?;
201        }
202        stack::set_parent(branch, &review.base)?;
203    }
204    Ok(1)
205}
206
207/// Retarget a finished branch's children onto its parent, then detach the
208/// branch itself. The children's recorded fork points depend on `landing`: see
209/// [`Landing`].
210pub(crate) fn cleanup_finished_branch(
211    review_provider: &dyn ReviewProvider,
212    branch: &str,
213    landing: Landing,
214    dry_run: bool,
215) -> Result<()> {
216    let parent = stack::parent_of(branch)?;
217    let descendants = stack::branch_and_descendants(branch)?;
218    let direct_children: Vec<_> = descendants
219        .into_iter()
220        .skip(1)
221        .filter_map(|child| match stack::parent_of(&child) {
222            Ok(Some(child_parent)) if child_parent == branch => Some(Ok(child)),
223            Ok(_) => None,
224            Err(error) => Some(Err(error)),
225        })
226        .collect::<Result<_>>()?;
227
228    for child in direct_children {
229        match parent.as_deref() {
230            Some(parent) => {
231                anstream::println!(
232                    "{} retarget {} -> {}",
233                    if dry_run { "would" } else { "will" },
234                    style::branch(&child),
235                    style::branch(parent)
236                );
237                update_child_review_base(review_provider, &child, parent, dry_run)?;
238                if !dry_run {
239                    match landing {
240                        // Record the fork point off the merged branch before
241                        // retargeting, so the next restack replays only the
242                        // child's own commits even after a squash merge.
243                        Landing::Merged => {
244                            if let Ok(base) = git::merge_base(branch, &child) {
245                                stack::set_base(&child, &base)?;
246                            }
247                        }
248                        // A closed branch's commits landed nowhere, and the
249                        // child was written on top of them: drop the fork
250                        // point so the restack replays everything the child
251                        // has that its new parent lacks, closed commits
252                        // included.
253                        Landing::Closed => stack::unset_base(&child)?,
254                    }
255                    stack::set_parent(&child, parent)?;
256                }
257            }
258            None => {
259                anstream::println!(
260                    "{} detach {}",
261                    if dry_run { "would" } else { "will" },
262                    style::branch(&child)
263                );
264                if !dry_run {
265                    stack::unset_parent(&child)?;
266                    stack::unset_base(&child)?;
267                }
268            }
269        }
270    }
271    anstream::println!(
272        "{} detach {}",
273        if dry_run { "would" } else { "will" },
274        style::branch(branch)
275    );
276    if !dry_run {
277        stack::unset_parent(branch)?;
278        stack::unset_base(branch)?;
279    }
280
281    Ok(())
282}
283
284pub(crate) fn cleanup_branch_deletion(
285    branch: &str,
286    current_branch: &str,
287    landing: Landing,
288    dry_run: bool,
289    delete_branch: bool,
290) -> Result<()> {
291    if !delete_branch {
292        return Ok(());
293    }
294
295    // The checked out branch cannot be deleted; keep it and let the user
296    // switch away instead of failing the rest of the cleanup.
297    if branch == current_branch {
298        anstream::println!(
299            "{}",
300            style::dim(&format!(
301                "kept {branch}: cannot delete the checked out branch"
302            ))
303        );
304        return Ok(());
305    }
306
307    // Nor can a branch another worktree holds - but a worktree git-stk created
308    // for this branch is ours to remove, and must go first: git refuses to
309    // delete a branch a worktree still holds.
310    if let Some(path) = git::worktree_holding(branch)? {
311        let ours = stack::owned_worktree(branch).is_some_and(|owned| git::same_path(&owned, &path));
312        if !ours {
313            // The user's own worktree. Naming where it lives keeps the rest of
314            // the cleanup running - a landed stack should not stop halfway
315            // because one branch has a worktree parked on it.
316            anstream::println!(
317                "{}",
318                style::dim(&format!(
319                    "kept {branch}: checked out in the worktree at {}",
320                    git::display_path(&path)
321                ))
322            );
323            return Ok(());
324        }
325
326        // Ours, but not ours to throw away: uncommitted work in it is not
327        // covered by any snapshot, so keep it and say so.
328        if git::worktree_has_changes(&path) {
329            anstream::println!(
330                "{}",
331                style::dim(&format!(
332                    "kept {branch}: its worktree at {} has uncommitted changes",
333                    git::display_path(&path)
334                ))
335            );
336            return Ok(());
337        }
338
339        anstream::println!(
340            "{} remove worktree {}",
341            if dry_run { "would" } else { "will" },
342            git::display_path(&path)
343        );
344        if !dry_run {
345            git::worktree_remove(&path)?;
346            stack::unset_owned_worktree(branch)?;
347        }
348    }
349
350    // A closed branch's commits are in no other branch, so say so on the way
351    // out and name the way back: the snapshot `undo` restores is the only
352    // handle most people will have.
353    let caveat = match landing {
354        Landing::Merged => String::new(),
355        Landing::Closed => style::dim(" (closed, not merged - `git stk undo` restores it)"),
356    };
357    anstream::println!(
358        "{} delete branch {}{caveat}",
359        if dry_run { "would" } else { "will" },
360        style::branch(branch)
361    );
362    if !dry_run {
363        git::delete_branch(branch)?;
364    }
365
366    Ok(())
367}
368
369fn update_child_review_base(
370    review_provider: &dyn ReviewProvider,
371    child: &str,
372    parent: &str,
373    dry_run: bool,
374) -> Result<()> {
375    let Some(review) = review_provider.review_for_branch(child)? else {
376        return Ok(());
377    };
378
379    if review.state == ReviewState::Merged || review.base == parent {
380        return Ok(());
381    }
382
383    anstream::println!(
384        "{} update review {} -> {} {}",
385        if dry_run { "would" } else { "will" },
386        style::branch(&review.branch),
387        style::branch(parent),
388        style::dim(&format!("({})", review.id))
389    );
390    if !dry_run {
391        let output = review_provider.update_review_base(&review, parent)?;
392        if !output.is_empty() {
393            println!("{output}");
394        }
395    }
396
397    Ok(())
398}