git_trim/
args.rs

1use std::collections::HashSet;
2use std::fmt::Debug;
3use std::hash::Hash;
4use std::iter::FromIterator;
5use std::mem::discriminant;
6use std::process::exit;
7use std::str::FromStr;
8
9use clap::Parser;
10use thiserror::Error;
11
12#[derive(Parser, Default)]
13#[clap(
14    version,
15    about = "Automatically trims your tracking branches whose upstream branches are merged or stray.",
16    long_about = "Automatically trims your tracking branches whose upstream branches are merged or stray.
17`git-trim` is a missing companion to the `git fetch --prune` and a proper, safer, faster alternative to your `<bash oneliner HERE>`."
18)]
19pub struct Args {
20    /// Comma separated multiple names of branches.
21    /// All the other branches are compared with the upstream branches of those branches.
22    /// [default: branches that tracks `git symbolic-ref refs/remotes/*/HEAD`] [config: trim.bases]
23    ///
24    /// The default value is a branch that tracks `git symbolic-ref refs/remotes/*/HEAD`.
25    /// They might not be reflected correctly when the HEAD branch of your remote repository is changed.
26    /// You can see the changed HEAD branch name with `git remote show <remote>`
27    /// and apply it to your local repository with `git remote set-head <remote> --auto`.
28    #[clap(short, long, value_delimiter = ',', aliases=&["base"])]
29    pub bases: Vec<String>,
30
31    /// Comma separated multiple glob patterns (e.g. `release-*`, `feature/*`) of branches that should never be deleted.
32    /// [config: trim.protected]
33    #[clap(short, long, value_delimiter = ',')]
34    pub protected: Vec<String>,
35
36    /// Do not update remotes
37    /// [config: trim.update]
38    #[clap(long)]
39    pub no_update: bool,
40    #[clap(long, hide(true))]
41    pub update: bool,
42
43    /// Prevents too frequent updates. Seconds between updates in seconds. 0 to disable.
44    /// [default: 5] [config: trim.updateInterval]
45    #[clap(long)]
46    pub update_interval: Option<u64>,
47
48    /// Do not ask confirm
49    /// [config: trim.confirm]
50    #[clap(long)]
51    pub no_confirm: bool,
52    #[clap(long, hide(true))]
53    pub confirm: bool,
54
55    /// Do not detach when HEAD is about to be deleted
56    /// [config: trim.detach]
57    #[clap(long)]
58    pub no_detach: bool,
59    #[clap(long, hide(true))]
60    pub detach: bool,
61
62    /// Comma separated values of `<delete range>[:<remote name>]`.
63    /// Delete range is one of the `merged, merged-local, merged-remote, stray, diverged, local, remote`.
64    /// `:<remote name>` is only necessary to a `<delete range>` when the range is applied to remote branches.
65    /// You can use `*` as `<remote name>` to delete a range of branches from all remotes.
66    /// [default : `merged:origin`] [config: trim.delete]
67    ///
68    /// `merged` implies `merged-local,merged-remote`.
69    ///
70    /// `merged-local` will delete merged tracking local branches.
71    /// `merged-remote:<remote>` will delete merged upstream branches from `<remote>`.
72    /// `stray` will delete tracking local branches, which is not merged, but the upstream is gone.
73    /// `diverged:<remote>` will delete merged tracking local branches, and their upstreams from `<remote>` even if the upstreams are not merged and diverged from local ones.
74    /// `local` will delete non-tracking merged local branches.
75    /// `remote:<remote>` will delete non-upstream merged remote tracking branches.
76    /// Use with caution when you are using other than `merged`. It might lose changes, and even nuke repositories.
77    #[clap(short, long, value_delimiter = ',')]
78    pub delete: Vec<DeleteRange>,
79
80    /// Do not delete branches, show what branches will be deleted.
81    #[clap(long)]
82    pub dry_run: bool,
83}
84
85impl Args {
86    pub fn update(&self) -> Option<bool> {
87        exclusive_bool(("update", self.update), ("no-update", self.no_update))
88    }
89
90    pub fn confirm(&self) -> Option<bool> {
91        exclusive_bool(("confirm", self.confirm), ("no-confirm", self.no_confirm))
92    }
93
94    pub fn detach(&self) -> Option<bool> {
95        exclusive_bool(("detach", self.detach), ("no-detach", self.no_detach))
96    }
97}
98
99fn exclusive_bool(
100    (name_pos, value_pos): (&str, bool),
101    (name_neg, value_neg): (&str, bool),
102) -> Option<bool> {
103    if value_pos && value_neg {
104        eprintln!(
105            "Error: Flag '{}' and '{}' cannot be used simultaneously",
106            name_pos, name_neg,
107        );
108        exit(-1);
109    }
110
111    if value_pos {
112        Some(true)
113    } else if value_neg {
114        Some(false)
115    } else {
116        None
117    }
118}
119
120#[derive(Hash, Eq, PartialEq, Clone, Debug)]
121pub enum Scope {
122    All,
123    Scoped(String),
124}
125
126impl FromStr for Scope {
127    type Err = ScopeParseError;
128
129    fn from_str(s: &str) -> Result<Self, Self::Err> {
130        match s.trim() {
131            "" => Err(ScopeParseError {
132                message: "Scope is empty".to_owned(),
133            }),
134            "*" => Ok(Scope::All),
135            scope => Ok(Scope::Scoped(scope.to_owned())),
136        }
137    }
138}
139
140#[derive(Error, Debug)]
141#[error("{message}")]
142pub struct ScopeParseError {
143    message: String,
144}
145
146#[derive(Hash, Eq, PartialEq, Clone, Debug)]
147pub enum DeleteRange {
148    Merged(Scope),
149    MergedLocal,
150    MergedRemote(Scope),
151    Stray,
152    Diverged(Scope),
153    Local,
154    Remote(Scope),
155}
156
157#[derive(Hash, Eq, PartialEq, Clone, Debug)]
158pub enum DeleteUnit {
159    MergedLocal,
160    MergedRemote(Scope),
161    Stray,
162    Diverged(Scope),
163    MergedNonTrackingLocal,
164    MergedNonUpstreamRemoteTracking(Scope),
165}
166
167impl FromStr for DeleteRange {
168    type Err = DeleteParseError;
169
170    fn from_str(arg: &str) -> Result<DeleteRange, Self::Err> {
171        let some_pair: Vec<_> = arg.splitn(2, ':').map(str::trim).collect();
172        match *some_pair.as_slice() {
173            ["merged", remote] => Ok(DeleteRange::Merged(remote.parse()?)),
174            ["stray"] => Ok(DeleteRange::Stray),
175            ["diverged", remote] => Ok(DeleteRange::Diverged(remote.parse()?)),
176            ["merged-local"] => Ok(DeleteRange::MergedLocal),
177            ["merged-remote", remote] => Ok(DeleteRange::MergedRemote(remote.parse()?)),
178            ["local"] => Ok(DeleteRange::Local),
179            ["remote", remote] => Ok(DeleteRange::Remote(remote.parse()?)),
180            _ => Err(DeleteParseError::InvalidDeleteRangeFormat(arg.to_owned())),
181        }
182    }
183}
184
185impl DeleteRange {
186    fn to_delete_units(&self) -> Vec<DeleteUnit> {
187        match self {
188            DeleteRange::Merged(scope) => vec![
189                DeleteUnit::MergedLocal,
190                DeleteUnit::MergedRemote(scope.clone()),
191            ],
192            DeleteRange::MergedLocal => vec![DeleteUnit::MergedLocal],
193            DeleteRange::MergedRemote(scope) => vec![DeleteUnit::MergedRemote(scope.clone())],
194            DeleteRange::Stray => vec![DeleteUnit::Stray],
195            DeleteRange::Diverged(scope) => vec![DeleteUnit::Diverged(scope.clone())],
196            DeleteRange::Local => vec![DeleteUnit::MergedNonTrackingLocal],
197            DeleteRange::Remote(scope) => {
198                vec![DeleteUnit::MergedNonUpstreamRemoteTracking(scope.clone())]
199            }
200        }
201    }
202
203    pub fn merged_origin() -> Vec<Self> {
204        use DeleteRange::*;
205        vec![
206            MergedLocal,
207            MergedRemote(Scope::Scoped("origin".to_string())),
208        ]
209    }
210}
211
212#[derive(Error, Debug)]
213pub enum DeleteParseError {
214    #[error("Invalid delete range format `{0}`")]
215    InvalidDeleteRangeFormat(String),
216    #[error("Scope parse error for delete range while parsing scope: {0}")]
217    ScopeParseError(#[from] ScopeParseError),
218}
219
220#[derive(Debug, Clone, Eq, PartialEq, Default)]
221pub struct DeleteFilter(HashSet<DeleteUnit>);
222
223impl DeleteFilter {
224    pub fn scan_tracking(&self) -> bool {
225        self.0.iter().any(|unit| {
226            matches!(
227                unit,
228                DeleteUnit::MergedLocal
229                    | DeleteUnit::MergedRemote(_)
230                    | DeleteUnit::Stray
231                    | DeleteUnit::Diverged(_)
232            )
233        })
234    }
235
236    pub fn scan_non_tracking_local(&self) -> bool {
237        self.0.contains(&DeleteUnit::MergedNonTrackingLocal)
238    }
239
240    pub fn scan_non_upstream_remote(&self, remote: &str) -> bool {
241        for unit in self.0.iter() {
242            match unit {
243                DeleteUnit::MergedNonUpstreamRemoteTracking(Scope::All) => return true,
244                DeleteUnit::MergedNonUpstreamRemoteTracking(Scope::Scoped(specific))
245                    if specific == remote =>
246                {
247                    return true
248                }
249                _ => {}
250            }
251        }
252        false
253    }
254
255    pub fn delete_merged_local(&self) -> bool {
256        self.0.contains(&DeleteUnit::MergedLocal)
257    }
258
259    pub fn delete_merged_remote(&self, remote: &str) -> bool {
260        for unit in self.0.iter() {
261            match unit {
262                DeleteUnit::MergedRemote(Scope::All) => return true,
263                DeleteUnit::MergedRemote(Scope::Scoped(specific)) if specific == remote => {
264                    return true
265                }
266                _ => {}
267            }
268        }
269        false
270    }
271
272    pub fn delete_stray(&self) -> bool {
273        self.0.contains(&DeleteUnit::Stray)
274    }
275
276    pub fn delete_diverged(&self, remote: &str) -> bool {
277        for unit in self.0.iter() {
278            match unit {
279                DeleteUnit::Diverged(Scope::All) => return true,
280                DeleteUnit::Diverged(Scope::Scoped(specific)) if specific == remote => return true,
281                _ => {}
282            }
283        }
284        false
285    }
286
287    pub fn delete_merged_non_tracking_local(&self) -> bool {
288        self.0.contains(&DeleteUnit::MergedNonTrackingLocal)
289    }
290
291    pub fn delete_merged_non_upstream_remote_tracking(&self, remote: &str) -> bool {
292        for filter in self.0.iter() {
293            match filter {
294                DeleteUnit::MergedNonUpstreamRemoteTracking(Scope::All) => return true,
295                DeleteUnit::MergedNonUpstreamRemoteTracking(Scope::Scoped(specific))
296                    if specific == remote =>
297                {
298                    return true
299                }
300                _ => {}
301            }
302        }
303        false
304    }
305}
306
307impl FromIterator<DeleteUnit> for DeleteFilter {
308    fn from_iter<I>(iter: I) -> Self
309    where
310        I: IntoIterator<Item = DeleteUnit>,
311    {
312        use DeleteUnit::*;
313        use Scope::*;
314
315        let mut result = HashSet::new();
316        for unit in iter.into_iter() {
317            match unit {
318                MergedLocal | Stray | MergedNonTrackingLocal => {
319                    result.insert(unit.clone());
320                }
321                MergedRemote(All) | Diverged(All) | MergedNonUpstreamRemoteTracking(All) => {
322                    result.retain(|x| discriminant(x) != discriminant(&unit));
323                    result.insert(unit.clone());
324                }
325                MergedRemote(_) => {
326                    if !result.contains(&MergedRemote(All)) {
327                        result.insert(unit.clone());
328                    }
329                }
330                Diverged(_) => {
331                    if !result.contains(&Diverged(All)) {
332                        result.insert(unit.clone());
333                    }
334                }
335                MergedNonUpstreamRemoteTracking(_) => {
336                    if !result.contains(&MergedNonUpstreamRemoteTracking(All)) {
337                        result.insert(unit.clone());
338                    }
339                }
340            }
341        }
342
343        Self(result)
344    }
345}
346
347impl FromIterator<DeleteRange> for DeleteFilter {
348    fn from_iter<I>(iter: I) -> Self
349    where
350        I: IntoIterator<Item = DeleteRange>,
351    {
352        Self::from_iter(iter.into_iter().flat_map(|x| x.to_delete_units()))
353    }
354}