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