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 provider = detect_provider()?;
37    let review_provider = review_provider(provider.kind);
38    let mut cleaned = 0;
39    let mut skipped = 0;
40
41    for branch in branches {
42        let Some(review) = review_provider.review_for_branch(&branch)? else {
43            println!("skipped {branch}: no {} review found", provider.kind);
44            skipped += 1;
45            continue;
46        };
47
48        if review.state != ReviewState::Merged {
49            println!("skipped {branch}: review {} is {}", review.id, review.state);
50            skipped += 1;
51            continue;
52        }
53
54        cleanup_merged_branch(review_provider.as_ref(), &branch, dry_run)?;
55        cleanup_branch_deletion(&branch, &current_branch, dry_run, !keep_branch)?;
56        cleaned += 1;
57    }
58
59    println!("cleanup complete: {cleaned} cleaned, {skipped} skipped");
60    Ok(())
61}
62
63pub(crate) fn cleanup_merged_branch(
64    review_provider: &dyn ReviewProvider,
65    branch: &str,
66    dry_run: bool,
67) -> Result<()> {
68    let parent = stack::parent_for_branch(branch)?;
69    let descendants = stack::branch_and_descendants(branch)?;
70    let direct_children: Vec<_> = descendants
71        .into_iter()
72        .skip(1)
73        .filter_map(|child| match stack::parent_for_branch(&child) {
74            Ok(Some(child_parent)) if child_parent == branch => Some(Ok(child)),
75            Ok(_) => None,
76            Err(error) => Some(Err(error)),
77        })
78        .collect::<Result<_>>()?;
79
80    for child in direct_children {
81        match parent.as_deref() {
82            Some(parent) => {
83                println!(
84                    "{} retarget {child} -> {parent}",
85                    if dry_run { "would" } else { "will" }
86                );
87                update_child_review_base(review_provider, &child, parent, dry_run)?;
88                if !dry_run {
89                    // Record the fork point off the merged branch before
90                    // retargeting, so the next restack replays only the
91                    // child's own commits even after a squash merge.
92                    if let Ok(base) = git::merge_base(branch, &child) {
93                        stack::set_base_for_branch(&child, &base)?;
94                    }
95                    stack::set_parent_for_branch(&child, parent)?;
96                }
97            }
98            None => {
99                println!("{} detach {child}", if dry_run { "would" } else { "will" });
100                if !dry_run {
101                    stack::unset_parent_for_branch(&child)?;
102                    stack::unset_base_for_branch(&child)?;
103                }
104            }
105        }
106    }
107
108    println!("{} detach {branch}", if dry_run { "would" } else { "will" });
109    if !dry_run {
110        stack::unset_parent_for_branch(branch)?;
111        stack::unset_base_for_branch(branch)?;
112    }
113
114    Ok(())
115}
116
117pub(crate) fn cleanup_branch_deletion(
118    branch: &str,
119    current_branch: &str,
120    dry_run: bool,
121    delete_branch: bool,
122) -> Result<()> {
123    if !delete_branch {
124        return Ok(());
125    }
126
127    // The checked out branch cannot be deleted; keep it and let the user
128    // switch away instead of failing the rest of the cleanup.
129    if branch == current_branch {
130        println!("kept {branch}: cannot delete the checked out branch");
131        return Ok(());
132    }
133
134    println!(
135        "{} delete branch {branch}",
136        if dry_run { "would" } else { "will" }
137    );
138    if !dry_run {
139        git::delete_branch(branch)?;
140    }
141
142    Ok(())
143}
144
145fn update_child_review_base(
146    review_provider: &dyn ReviewProvider,
147    child: &str,
148    parent: &str,
149    dry_run: bool,
150) -> Result<()> {
151    let Some(review) = review_provider.review_for_branch(child)? else {
152        return Ok(());
153    };
154
155    if review.state == ReviewState::Merged || review.base == parent {
156        return Ok(());
157    }
158
159    println!(
160        "{} update review {} -> {} ({})",
161        if dry_run { "would" } else { "will" },
162        review.branch,
163        parent,
164        review.id
165    );
166    if !dry_run {
167        let output = review_provider.update_review_base(&review, parent)?;
168        if !output.is_empty() {
169            println!("{output}");
170        }
171    }
172
173    Ok(())
174}