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::{ReviewProvider, ReviewState, detect_provider, review_provider};
8use crate::style;
9use crate::{git, stack};
10
11/// Clean up local metadata for merged review requests and delete their
12/// branches.
13#[derive(Debug, clap::Args)]
14pub struct Cleanup {
15    #[arg(add = ArgValueCompleter::new(completions::branch_candidates))]
16    branch: Option<String>,
17    /// Print what would change without updating local metadata.
18    #[arg(long, action = ArgAction::SetTrue)]
19    dry_run: bool,
20    /// Keep cleaned merged branches instead of deleting them.
21    #[arg(long, action = ArgAction::SetTrue)]
22    keep_branch: bool,
23}
24
25impl Run for Cleanup {
26    fn run(self) -> Result<()> {
27        cleanup(self.branch.as_deref(), self.dry_run, self.keep_branch)
28    }
29}
30
31pub fn cleanup(branch: Option<&str>, dry_run: bool, keep_branch: bool) -> Result<()> {
32    let branch = branch
33        .map(str::to_owned)
34        .map_or_else(git::current_branch, Ok)?;
35    let branches = stack::branch_and_descendants(&branch)?;
36    let current_branch = git::current_branch()?;
37    let local_branches = git::local_branches()?;
38    let provider = detect_provider()?;
39    let review_provider = review_provider(provider.kind);
40    let mut cleaned = 0;
41    let mut skipped = 0;
42    let mut retargeted = 0;
43
44    // Refresh the stack overview ledger while the merged branches and their
45    // reviews are still resolvable, so their entries get restyled rather
46    // than dropped - mirroring sync.
47    let branch_parents = stack::branch_parents(&branches)?;
48    crate::notes::update_stack_notes(review_provider.as_ref(), &branch_parents, dry_run)?;
49
50    for branch in branches {
51        retargeted +=
52            recover_deleted_parent(review_provider.as_ref(), &branch, &local_branches, dry_run)?;
53        // Closed-inclusive so a review closed without merging gets a
54        // truthful skip instead of "no review found". Only merged reviews
55        // are ever cleaned: a closed review's work is not in the trunk.
56        let Some(review) = review_provider.review_for_branch_including_closed(&branch)? else {
57            anstream::println!(
58                "{}",
59                style::dim(&format!(
60                    "skipped {branch}: no {} review found",
61                    provider.kind
62                ))
63            );
64            skipped += 1;
65            continue;
66        };
67
68        if review.state != ReviewState::Merged {
69            anstream::println!(
70                "{}",
71                style::dim(&format!(
72                    "skipped {branch}: review {} is {}",
73                    review.id, review.state
74                ))
75            );
76            skipped += 1;
77            continue;
78        }
79
80        cleanup_merged_branch(review_provider.as_ref(), &branch, dry_run)?;
81        cleanup_branch_deletion(&branch, &current_branch, dry_run, !keep_branch)?;
82        cleaned += 1;
83    }
84
85    let retargeted_note = if retargeted > 0 {
86        format!(", {retargeted} retargeted")
87    } else {
88        String::new()
89    };
90    anstream::println!(
91        "{}",
92        style::success(&format!(
93            "cleanup complete: {cleaned} cleaned, {skipped} skipped{retargeted_note}"
94        ))
95    );
96    Ok(())
97}
98
99/// A merged parent deleted remotely (and pruned locally) leaves `branch`
100/// pointing at nothing, but the merged review still remembers its base.
101/// Retarget past the gap; the recorded fork point stays valid because it
102/// lives in the branch's own history. Returns how many branches moved.
103fn recover_deleted_parent(
104    review_provider: &dyn ReviewProvider,
105    branch: &str,
106    local_branches: &[String],
107    dry_run: bool,
108) -> Result<usize> {
109    let Some(parent) = stack::parent_for_branch(branch)? else {
110        return Ok(0);
111    };
112    if local_branches.contains(&parent) {
113        return Ok(0);
114    }
115
116    // Provider lookups go by ref name, so the review outlives the branch.
117    // Best effort: anything unresolved stays for `git stk repair`.
118    let Ok(Some(review)) = review_provider.review_for_branch(&parent) else {
119        return Ok(0);
120    };
121    if review.branch != parent
122        || review.state != ReviewState::Merged
123        || review.base == *branch
124        || !local_branches.contains(&review.base)
125    {
126        return Ok(0);
127    }
128
129    anstream::println!(
130        "{}: parent {} is gone, but review {} merged into {}",
131        style::branch(branch),
132        style::branch(&parent),
133        review.id,
134        style::branch(&review.base)
135    );
136    anstream::println!(
137        "{} retarget {} -> {}",
138        if dry_run { "would" } else { "will" },
139        style::branch(branch),
140        style::branch(&review.base)
141    );
142    update_child_review_base(review_provider, branch, &review.base, dry_run)?;
143    if !dry_run {
144        stack::set_parent_for_branch(branch, &review.base)?;
145    }
146    Ok(1)
147}
148
149pub(crate) fn cleanup_merged_branch(
150    review_provider: &dyn ReviewProvider,
151    branch: &str,
152    dry_run: bool,
153) -> Result<()> {
154    let parent = stack::parent_for_branch(branch)?;
155    let descendants = stack::branch_and_descendants(branch)?;
156    let direct_children: Vec<_> = descendants
157        .into_iter()
158        .skip(1)
159        .filter_map(|child| match stack::parent_for_branch(&child) {
160            Ok(Some(child_parent)) if child_parent == branch => Some(Ok(child)),
161            Ok(_) => None,
162            Err(error) => Some(Err(error)),
163        })
164        .collect::<Result<_>>()?;
165
166    for child in direct_children {
167        match parent.as_deref() {
168            Some(parent) => {
169                anstream::println!(
170                    "{} retarget {} -> {}",
171                    if dry_run { "would" } else { "will" },
172                    style::branch(&child),
173                    style::branch(parent)
174                );
175                update_child_review_base(review_provider, &child, parent, dry_run)?;
176                if !dry_run {
177                    // Record the fork point off the merged branch before
178                    // retargeting, so the next restack replays only the
179                    // child's own commits even after a squash merge.
180                    if let Ok(base) = git::merge_base(branch, &child) {
181                        stack::set_base_for_branch(&child, &base)?;
182                    }
183                    stack::set_parent_for_branch(&child, parent)?;
184                }
185            }
186            None => {
187                anstream::println!(
188                    "{} detach {}",
189                    if dry_run { "would" } else { "will" },
190                    style::branch(&child)
191                );
192                if !dry_run {
193                    stack::unset_parent_for_branch(&child)?;
194                    stack::unset_base_for_branch(&child)?;
195                }
196            }
197        }
198    }
199
200    anstream::println!(
201        "{} detach {}",
202        if dry_run { "would" } else { "will" },
203        style::branch(branch)
204    );
205    if !dry_run {
206        stack::unset_parent_for_branch(branch)?;
207        stack::unset_base_for_branch(branch)?;
208    }
209
210    Ok(())
211}
212
213pub(crate) fn cleanup_branch_deletion(
214    branch: &str,
215    current_branch: &str,
216    dry_run: bool,
217    delete_branch: bool,
218) -> Result<()> {
219    if !delete_branch {
220        return Ok(());
221    }
222
223    // The checked out branch cannot be deleted; keep it and let the user
224    // switch away instead of failing the rest of the cleanup.
225    if branch == current_branch {
226        anstream::println!(
227            "{}",
228            style::dim(&format!(
229                "kept {branch}: cannot delete the checked out branch"
230            ))
231        );
232        return Ok(());
233    }
234
235    anstream::println!(
236        "{} delete branch {}",
237        if dry_run { "would" } else { "will" },
238        style::branch(branch)
239    );
240    if !dry_run {
241        git::delete_branch(branch)?;
242    }
243
244    Ok(())
245}
246
247fn update_child_review_base(
248    review_provider: &dyn ReviewProvider,
249    child: &str,
250    parent: &str,
251    dry_run: bool,
252) -> Result<()> {
253    let Some(review) = review_provider.review_for_branch(child)? else {
254        return Ok(());
255    };
256
257    if review.state == ReviewState::Merged || review.base == parent {
258        return Ok(());
259    }
260
261    anstream::println!(
262        "{} update review {} -> {} {}",
263        if dry_run { "would" } else { "will" },
264        style::branch(&review.branch),
265        style::branch(parent),
266        style::dim(&format!("({})", review.id))
267    );
268    if !dry_run {
269        let output = review_provider.update_review_base(&review, parent)?;
270        if !output.is_empty() {
271            println!("{output}");
272        }
273    }
274
275    Ok(())
276}