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