git_trim/
lib.rs

1pub mod args;
2mod branch;
3pub mod config;
4mod core;
5mod merge_tracker;
6mod simple_glob;
7mod subprocess;
8mod util;
9
10use std::collections::{HashMap, HashSet};
11use std::convert::TryFrom;
12
13use anyhow::{Context, Result};
14use git2::{Config as GitConfig, Error as GitError, ErrorCode, Repository};
15use log::*;
16
17use crate::args::DeleteFilter;
18use crate::branch::RemoteTrackingBranchStatus;
19pub use crate::branch::{
20    LocalBranch, Refname, RemoteBranch, RemoteBranchError, RemoteTrackingBranch,
21};
22use crate::core::{
23    get_direct_fetch_branches, get_non_tracking_local_branches,
24    get_non_upstream_remote_tracking_branches, get_remote_heads, get_tracking_branches, Classifier,
25    DirectFetchClassificationRequest, NonTrackingBranchClassificationRequest,
26    NonUpstreamBranchClassificationRequest, TrackingBranchClassificationRequest,
27};
28pub use crate::core::{ClassifiedBranch, SkipSuggestion, TrimPlan};
29use crate::merge_tracker::MergeTracker;
30pub use crate::subprocess::{ls_remote_head, remote_update, RemoteHead};
31pub use crate::util::ForceSendSync;
32
33pub struct Git {
34    pub repo: Repository,
35    pub config: GitConfig,
36}
37
38impl TryFrom<Repository> for Git {
39    type Error = GitError;
40
41    fn try_from(repo: Repository) -> Result<Self, Self::Error> {
42        let config = repo.config()?.snapshot()?;
43        Ok(Self { repo, config })
44    }
45}
46
47pub struct PlanParam<'a> {
48    pub bases: Vec<&'a str>,
49    pub protected_patterns: Vec<&'a str>,
50    pub delete: DeleteFilter,
51    pub detach: bool,
52}
53
54pub fn get_trim_plan(git: &Git, param: &PlanParam) -> Result<TrimPlan> {
55    let bases = resolve_bases(&git.repo, &git.config, &param.bases)?;
56    let base_upstreams: Vec<_> = bases
57        .iter()
58        .map(|b| match b {
59            BaseSpec::Local { upstream, .. } => upstream.clone(),
60            BaseSpec::Remote { remote, .. } => remote.clone(),
61        })
62        .collect();
63    trace!("bases: {:#?}", bases);
64
65    let tracking_branches = get_tracking_branches(git)?;
66    debug!("tracking_branches: {:#?}", tracking_branches);
67
68    let direct_fetch_branches = get_direct_fetch_branches(git)?;
69    debug!("direct_fetch_branches: {:#?}", direct_fetch_branches);
70
71    let non_tracking_branches = get_non_tracking_local_branches(git)?;
72    debug!("non_tracking_branches: {:#?}", non_tracking_branches);
73
74    let non_upstream_branches = get_non_upstream_remote_tracking_branches(git)?;
75    debug!("non_upstream_branches: {:#?}", non_upstream_branches);
76
77    let remote_heads = if param.delete.scan_tracking() {
78        let remotes: Vec<_> = direct_fetch_branches
79            .iter()
80            .map(|(_, r)| r.clone())
81            .collect();
82        get_remote_heads(git, &remotes)?
83    } else {
84        Vec::new()
85    };
86    debug!("remote_heads: {:#?}", remote_heads);
87
88    let merge_tracker = MergeTracker::with_base_upstreams(&git.repo, &git.config, &base_upstreams)?;
89    let mut classifier = Classifier::new(git, &merge_tracker);
90    let mut skipped = HashMap::new();
91
92    info!("Enqueue classification requests");
93    if param.delete.scan_tracking() {
94        for (local, upstream) in &tracking_branches {
95            for base in &base_upstreams {
96                classifier.queue_request(TrackingBranchClassificationRequest {
97                    base,
98                    local,
99                    upstream: upstream.as_ref(),
100                });
101            }
102        }
103
104        for (local, remote) in &direct_fetch_branches {
105            for base in &base_upstreams {
106                classifier.queue_request_with_context(
107                    DirectFetchClassificationRequest {
108                        base,
109                        local,
110                        remote,
111                    },
112                    &remote_heads,
113                );
114            }
115        }
116    } else {
117        for (local, upstream) in &tracking_branches {
118            if let Some(upstream) = upstream {
119                let remote = upstream.to_remote_branch(&git.repo)?.remote;
120                let suggestion = SkipSuggestion::TrackingRemote(remote);
121                skipped.insert(local.refname.clone(), suggestion.clone());
122                skipped.insert(upstream.refname.clone(), suggestion.clone());
123            } else {
124                skipped.insert(local.refname.clone(), SkipSuggestion::Tracking);
125            }
126        }
127
128        for (local, _) in &direct_fetch_branches {
129            skipped.insert(local.refname.clone(), SkipSuggestion::Tracking);
130        }
131    }
132
133    if param.delete.scan_non_tracking_local() {
134        for base in &base_upstreams {
135            for local in &non_tracking_branches {
136                classifier.queue_request(NonTrackingBranchClassificationRequest { base, local });
137            }
138        }
139    } else {
140        for local in &non_tracking_branches {
141            skipped.insert(local.refname.clone(), SkipSuggestion::NonTracking);
142        }
143    }
144
145    for base in &base_upstreams {
146        for remote_tracking in &non_upstream_branches {
147            let remote = remote_tracking.to_remote_branch(&git.repo)?;
148            if param.delete.scan_non_upstream_remote(&remote.remote) {
149                classifier.queue_request(NonUpstreamBranchClassificationRequest {
150                    base,
151                    remote: remote_tracking,
152                });
153            } else {
154                let remote = remote_tracking.to_remote_branch(&git.repo)?.remote;
155                skipped.insert(
156                    remote_tracking.refname.clone(),
157                    SkipSuggestion::NonUpstream(remote),
158                );
159            }
160        }
161    }
162
163    let classifications = classifier.classify()?;
164
165    let mut result = TrimPlan {
166        skipped,
167        to_delete: HashSet::new(),
168        preserved: Vec::new(),
169    };
170    for classification in classifications {
171        result.to_delete.extend(classification.result);
172    }
173
174    result.preserve_bases(&git.repo, &git.config, &bases)?;
175    result.preserve_protected(&git.repo, &param.protected_patterns)?;
176    result.preserve_non_heads_remotes(&git.repo)?;
177    result.preserve_worktree(&git.repo)?;
178    result.apply_delete_range_filter(&git.repo, &param.delete)?;
179
180    if !param.detach {
181        result.adjust_not_to_detach(&git.repo)?;
182    }
183
184    Ok(result)
185}
186
187#[derive(Debug)]
188pub(crate) enum BaseSpec<'a> {
189    Local {
190        #[allow(dead_code)] // used in `Debug`
191        pattern: &'a str,
192        local: LocalBranch,
193        upstream: RemoteTrackingBranch,
194    },
195    Remote {
196        pattern: &'a str,
197        remote: RemoteTrackingBranch,
198    },
199}
200
201impl<'a> BaseSpec<'a> {
202    fn is_local(&self, branch: &LocalBranch) -> bool {
203        matches!(self, BaseSpec::Local { local, .. } if local == branch)
204    }
205
206    fn covers_remote(&self, refname: &str) -> bool {
207        match self {
208            BaseSpec::Local { upstream, .. } if upstream.refname() == refname => true,
209            BaseSpec::Remote { remote, .. } if remote.refname() == refname => true,
210            _ => false,
211        }
212    }
213
214    fn remote_pattern(&self, refname: &str) -> Option<&str> {
215        match self {
216            BaseSpec::Remote { pattern, remote } if remote.refname() == refname => Some(pattern),
217            _ => None,
218        }
219    }
220}
221
222pub(crate) fn resolve_bases<'a>(
223    repo: &Repository,
224    config: &GitConfig,
225    bases: &[&'a str],
226) -> Result<Vec<BaseSpec<'a>>> {
227    let mut result = Vec::new();
228    for base in bases {
229        let reference = match repo.resolve_reference_from_short_name(base) {
230            Ok(reference) => reference,
231            Err(err) if err.code() == ErrorCode::NotFound => continue,
232            Err(err) => return Err(err.into()),
233        };
234
235        if reference.is_branch() {
236            let local = LocalBranch::try_from(&reference)?;
237            if let RemoteTrackingBranchStatus::Exists(upstream) =
238                local.fetch_upstream(repo, config)?
239            {
240                result.push(BaseSpec::Local {
241                    pattern: base,
242                    local,
243                    upstream,
244                })
245            }
246        } else {
247            let remote = RemoteTrackingBranch::try_from(&reference)?;
248            result.push(BaseSpec::Remote {
249                pattern: base,
250                remote,
251            })
252        }
253    }
254
255    Ok(result)
256}
257
258pub fn delete_local_branches(
259    repo: &Repository,
260    branches: &[&LocalBranch],
261    dry_run: bool,
262) -> Result<()> {
263    if branches.is_empty() {
264        return Ok(());
265    }
266
267    let detach_to = if repo.head_detached()? {
268        None
269    } else {
270        let head = repo.head()?;
271        let head_refname = head.name().context("non-utf8 head ref name")?;
272        if branches.iter().any(|branch| branch.refname == head_refname) {
273            Some(head)
274        } else {
275            None
276        }
277    };
278
279    if let Some(head) = detach_to {
280        subprocess::checkout(repo, head, dry_run)?;
281    }
282    subprocess::branch_delete(repo, branches, dry_run)?;
283
284    Ok(())
285}
286
287pub fn delete_remote_branches(
288    repo: &Repository,
289    remote_branches: &[RemoteBranch],
290    dry_run: bool,
291) -> Result<()> {
292    if remote_branches.is_empty() {
293        return Ok(());
294    }
295    let mut per_remote = HashMap::new();
296    for remote_branch in remote_branches {
297        let entry = per_remote
298            .entry(&remote_branch.remote)
299            .or_insert_with(Vec::new);
300        entry.push(remote_branch);
301    }
302    for (remote_name, remote_refnames) in per_remote.iter() {
303        subprocess::push_delete(repo, remote_name, remote_refnames, dry_run)?;
304    }
305    Ok(())
306}