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