Skip to main content

git_stk/commands/
cleanup.rs

1use anyhow::{Result, bail};
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.
11#[derive(Debug, clap::Args)]
12pub struct Cleanup {
13    #[arg(add = ArgValueCompleter::new(completions::branch_candidates))]
14    branch: Option<String>,
15    /// Print what would change without updating local metadata.
16    #[arg(long, action = ArgAction::SetTrue)]
17    dry_run: bool,
18    /// Delete cleaned merged branches after updating stack metadata.
19    #[arg(long, action = ArgAction::SetTrue)]
20    delete_branch: bool,
21}
22
23impl Run for Cleanup {
24    fn run(self) -> Result<()> {
25        cleanup(self.branch.as_deref(), self.dry_run, self.delete_branch)
26    }
27}
28
29pub fn cleanup(branch: Option<&str>, dry_run: bool, delete_branch: bool) -> Result<()> {
30    let branch = branch
31        .map(str::to_owned)
32        .map_or_else(git::current_branch, Ok)?;
33    let branches = stack::branch_and_descendants(&branch)?;
34    let current_branch = git::current_branch()?;
35    let provider = detect_provider()?;
36    let review_provider = review_provider(provider.kind);
37    let mut cleaned = 0;
38    let mut skipped = 0;
39
40    for branch in branches {
41        let Some(review) = review_provider.review_for_branch(&branch)? else {
42            println!("skipped {branch}: no {} review found", provider.kind);
43            skipped += 1;
44            continue;
45        };
46
47        if review.state != ReviewState::Merged {
48            println!("skipped {branch}: review {} is {}", review.id, review.state);
49            skipped += 1;
50            continue;
51        }
52
53        cleanup_merged_branch(review_provider.as_ref(), &branch, dry_run)?;
54        cleanup_branch_deletion(&branch, &current_branch, dry_run, delete_branch)?;
55        cleaned += 1;
56    }
57
58    println!("cleanup complete: {cleaned} cleaned, {skipped} skipped");
59    Ok(())
60}
61
62pub(crate) fn cleanup_merged_branch(
63    review_provider: &dyn ReviewProvider,
64    branch: &str,
65    dry_run: bool,
66) -> Result<()> {
67    let parent = stack::parent_for_branch(branch)?;
68    let descendants = stack::branch_and_descendants(branch)?;
69    let direct_children: Vec<_> = descendants
70        .into_iter()
71        .skip(1)
72        .filter_map(|child| match stack::parent_for_branch(&child) {
73            Ok(Some(child_parent)) if child_parent == branch => Some(Ok(child)),
74            Ok(_) => None,
75            Err(error) => Some(Err(error)),
76        })
77        .collect::<Result<_>>()?;
78
79    for child in direct_children {
80        match parent.as_deref() {
81            Some(parent) => {
82                println!(
83                    "{} retarget {child} -> {parent}",
84                    if dry_run { "would" } else { "will" }
85                );
86                update_child_review_base(review_provider, &child, parent, dry_run)?;
87                if !dry_run {
88                    // Record the fork point off the merged branch before
89                    // retargeting, so the next restack replays only the
90                    // child's own commits even after a squash merge.
91                    if let Ok(base) = git::merge_base(branch, &child) {
92                        stack::set_base_for_branch(&child, &base)?;
93                    }
94                    stack::set_parent_for_branch(&child, parent)?;
95                }
96            }
97            None => {
98                println!("{} detach {child}", if dry_run { "would" } else { "will" });
99                if !dry_run {
100                    stack::unset_parent_for_branch(&child)?;
101                    stack::unset_base_for_branch(&child)?;
102                }
103            }
104        }
105    }
106
107    println!("{} detach {branch}", if dry_run { "would" } else { "will" });
108    if !dry_run {
109        stack::unset_parent_for_branch(branch)?;
110        stack::unset_base_for_branch(branch)?;
111    }
112
113    Ok(())
114}
115
116pub(crate) fn cleanup_branch_deletion(
117    branch: &str,
118    current_branch: &str,
119    dry_run: bool,
120    delete_branch: bool,
121) -> Result<()> {
122    if !delete_branch {
123        return Ok(());
124    }
125
126    if branch == current_branch {
127        bail!("refusing to delete currently checked out branch {branch}");
128    }
129
130    println!(
131        "{} delete branch {branch}",
132        if dry_run { "would" } else { "will" }
133    );
134    if !dry_run {
135        git::delete_branch(branch)?;
136    }
137
138    Ok(())
139}
140
141fn update_child_review_base(
142    review_provider: &dyn ReviewProvider,
143    child: &str,
144    parent: &str,
145    dry_run: bool,
146) -> Result<()> {
147    let Some(review) = review_provider.review_for_branch(child)? else {
148        return Ok(());
149    };
150
151    if review.state == ReviewState::Merged || review.base == parent {
152        return Ok(());
153    }
154
155    println!(
156        "{} update review {} -> {} ({})",
157        if dry_run { "would" } else { "will" },
158        review.branch,
159        parent,
160        review.id
161    );
162    if !dry_run {
163        let output = review_provider.update_review_base(&review, parent)?;
164        if !output.is_empty() {
165            println!("{output}");
166        }
167    }
168
169    Ok(())
170}