git_bonsai/
app.rs

1/*
2 * Copyright 2021 Aurélien Gâteau <mail@agateau.com>
3 *
4 * This file is part of git-bonsai.
5 *
6 * Git-bonsai is free software: you can redistribute it and/or modify it under
7 * the terms of the GNU General Public License as published by the Free
8 * Software Foundation, either version 3 of the License, or (at your option)
9 * any later version.
10 *
11 * This program is distributed in the hope that it will be useful, but WITHOUT
12 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13 * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
14 * more details.
15 *
16 * You should have received a copy of the GNU General Public License along with
17 * this program.  If not, see <http://www.gnu.org/licenses/>.
18 */
19use std::collections::{HashMap, HashSet};
20use std::convert::From;
21use std::fmt;
22use std::path::PathBuf;
23
24use crate::appui::{AppUi, BranchToDeleteInfo};
25use crate::batchappui::BatchAppUi;
26use crate::cliargs::CliArgs;
27use crate::git::{BranchRestorer, GitError, Repository};
28use crate::interactiveappui::InteractiveAppUi;
29
30pub static DEFAULT_BRANCH_CONFIG_KEY: &str = "git-bonsai.default-branch";
31
32#[derive(Debug, PartialEq, Eq)]
33pub enum AppError {
34    Git(GitError),
35    UnsafeDelete,
36    InterruptedByUser,
37}
38
39impl From<GitError> for AppError {
40    fn from(error: GitError) -> Self {
41        AppError::Git(error)
42    }
43}
44
45impl fmt::Display for AppError {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        match self {
48            AppError::Git(error) => error.fmt(f),
49            AppError::UnsafeDelete => {
50                write!(f, "This branch cannot be deleted safely")
51            }
52            AppError::InterruptedByUser => {
53                write!(f, "Interrupted")
54            }
55        }
56    }
57}
58
59pub struct App {
60    repo: Repository,
61    protected_branches: HashSet<String>,
62    ui: Box<dyn AppUi>,
63    fetch: bool,
64}
65
66impl App {
67    pub fn new(args: &CliArgs, ui: Box<dyn AppUi>, repo_dir: &str) -> App {
68        let repo = Repository::new(&PathBuf::from(repo_dir));
69
70        let mut branches: HashSet<String> = HashSet::new();
71        for branch in repo
72            .get_config_keys("git-bonsai.protected-branches")
73            .unwrap()
74        {
75            branches.insert(branch.to_string());
76        }
77        for branch in &args.excluded {
78            branches.insert(branch.to_string());
79        }
80        App {
81            repo,
82            protected_branches: branches,
83            ui,
84            fetch: !args.no_fetch,
85        }
86    }
87
88    // Used by test code
89    #[allow(dead_code)]
90    pub fn get_protected_branches(&self) -> HashSet<String> {
91        self.protected_branches.clone()
92    }
93
94    pub fn is_working_tree_clean(&self) -> bool {
95        if self.repo.get_current_branch().is_none() {
96            self.ui.log_error("No current branch");
97            return false;
98        }
99        match self.repo.has_changes() {
100            Ok(has_changes) => {
101                if has_changes {
102                    self.ui
103                        .log_error("Can't work in a tree with uncommitted changes");
104                    return false;
105                }
106                true
107            }
108            Err(_) => {
109                self.ui.log_error("Failed to get working tree status");
110                false
111            }
112        }
113    }
114
115    /// Ask git the name of the default branch, and store the result in git config. If we can't
116    /// find it using git, fallback to asking the user.
117    pub fn find_default_branch_from_git(&self) -> Result<String, AppError> {
118        self.ui.log_info("Determining repository default branch");
119        let branch = match self.repo.find_default_branch() {
120            Ok(x) => x,
121            Err(err) => {
122                self.ui.log_error(&format!(
123                    "Can't determine default branch: {}",
124                    &err.to_string()
125                ));
126                return self.find_default_branch_from_user();
127            }
128        };
129        self.repo
130            .set_config_key(DEFAULT_BRANCH_CONFIG_KEY, &branch)?;
131        self.ui.log_info(&format!("Default branch is {}", branch));
132        Ok(branch)
133    }
134
135    /// Ask the user the name of the default branch, and store the result in git config
136    pub fn find_default_branch_from_user(&self) -> Result<String, AppError> {
137        let branch = match self.ui.select_default_branch(&self.repo.list_branches()?) {
138            Some(x) => x,
139            None => {
140                return Err(AppError::InterruptedByUser);
141            }
142        };
143        self.repo
144            .set_config_key(DEFAULT_BRANCH_CONFIG_KEY, &branch)?;
145        Ok(branch)
146    }
147
148    /// Return the default branch stored in git config, if any
149    pub fn get_default_branch(&self) -> Result<Option<String>, AppError> {
150        match self.repo.get_config_keys(DEFAULT_BRANCH_CONFIG_KEY) {
151            Ok(values) => Ok(if values.len() != 1 {
152                None
153            } else {
154                Some(values[0].clone())
155            }),
156            Err(x) => Err(AppError::Git(x)),
157        }
158    }
159
160    pub fn fetch_changes(&self) -> Result<(), AppError> {
161        self.ui.log_info("Fetching changes");
162        self.repo.fetch()?;
163        Ok(())
164    }
165
166    pub fn update_tracking_branches(&self) -> Result<(), AppError> {
167        let branches = match self.repo.list_tracking_branches() {
168            Ok(x) => x,
169            Err(x) => {
170                self.ui.log_error("Failed to list tracking branches");
171                return Err(AppError::Git(x));
172            }
173        };
174
175        let _restorer = BranchRestorer::new(&self.repo);
176        for branch in branches {
177            self.ui.log_info(&format!("Updating {}", branch));
178            if let Err(x) = self.repo.checkout(&branch) {
179                self.ui.log_error("Failed to checkout branch");
180                return Err(AppError::Git(x));
181            }
182            if let Err(_x) = self.repo.update_branch() {
183                self.ui.log_warning("Failed to update branch");
184                // This is not wrong, it can happen if the branches have diverged
185                // let's continue
186            }
187        }
188        Ok(())
189    }
190    pub fn remove_merged_branches(&self) -> Result<(), AppError> {
191        let to_delete = self.get_deletable_branches()?;
192
193        if to_delete.is_empty() {
194            self.ui.log_info("No deletable branches");
195            return Ok(());
196        }
197
198        let selected_branches = self.ui.select_branches_to_delete(&to_delete);
199        if selected_branches.is_empty() {
200            return Ok(());
201        }
202
203        let branch_names: Vec<String> = selected_branches
204            .iter()
205            .map(|x| x.name.to_string())
206            .collect();
207        self.delete_branches(&branch_names[..])?;
208        Ok(())
209    }
210
211    /// Delete the specified branches, takes care of checking out another branch if we are deleting
212    /// the current one
213    fn delete_branches(&self, branches: &[String]) -> Result<(), AppError> {
214        let current_branch = self.repo.get_current_branch().unwrap();
215
216        let mut current_branch_deleted = false;
217        let default_branch = self.get_default_branch().unwrap().unwrap();
218
219        match self.repo.checkout(&default_branch) {
220            Ok(()) => (),
221            Err(x) => {
222                let msg = format!("Failed to switch to default branch '{}'", default_branch);
223                self.ui.log_error(&msg);
224                return Err(AppError::Git(x));
225            }
226        }
227
228        for branch in branches {
229            self.ui.log_info(&format!("Deleting {}", branch));
230
231            if self.safe_delete_branch(branch).is_err() {
232                self.ui.log_warning("Failed to delete branch");
233            } else if *branch == current_branch {
234                current_branch_deleted = true;
235            }
236        }
237
238        if !current_branch_deleted {
239            self.repo.checkout(&current_branch)?;
240        }
241        Ok(())
242    }
243
244    fn get_deletable_branches(&self) -> Result<Vec<BranchToDeleteInfo>, AppError> {
245        let deletable_branches: Vec<BranchToDeleteInfo> = match self.repo.list_branches() {
246            Ok(x) => x,
247            Err(x) => {
248                self.ui.log_error("Failed to list branches");
249                return Err(AppError::Git(x));
250            }
251        }
252        .iter()
253        .filter(|&x| !self.protected_branches.contains(x))
254        .map(|branch| {
255            let contained_in: HashSet<String> = match self.repo.list_branches_containing(branch) {
256                Ok(x) => x,
257                Err(_x) => {
258                    self.ui
259                        .log_error(&format!("Failed to list branches containing {}", branch));
260                    [].to_vec()
261                }
262            }
263            .iter()
264            .filter(|&x| x != branch)
265            .cloned()
266            .collect();
267
268            BranchToDeleteInfo {
269                name: branch.to_string(),
270                contained_in,
271            }
272        })
273        .filter(|x| !x.contained_in.is_empty())
274        .collect();
275
276        Ok(deletable_branches)
277    }
278
279    fn is_sha1_contained_in_another_branch(
280        &self,
281        sha1: &str,
282        branches: &HashSet<String>,
283    ) -> Result<bool, GitError> {
284        for branch in self.repo.list_branches_containing(sha1).unwrap() {
285            if !branches.contains(&branch) {
286                return Ok(true);
287            }
288        }
289        Ok(false)
290    }
291
292    pub fn do_delete_identical_branches(
293        &self,
294        sha1: &str,
295        branch_set: &HashSet<String>,
296    ) -> Result<(), AppError> {
297        let unprotected_branch_set: HashSet<_> =
298            branch_set.difference(&self.protected_branches).collect();
299        if !self
300            .is_sha1_contained_in_another_branch(sha1, branch_set)
301            .unwrap()
302        {
303            let contains_protected_branches = unprotected_branch_set.len() < branch_set.len();
304            let branches: Vec<String> = unprotected_branch_set
305                .iter()
306                .map(|x| x.to_string())
307                .collect();
308            if !contains_protected_branches {
309                let selected_branches: Vec<String> = self
310                    .ui
311                    .select_identical_branches_to_delete_keep_one(&branches)
312                    .iter()
313                    .map(|x| x.to_string())
314                    .collect();
315                self.delete_branches(&selected_branches)?;
316                return Ok(());
317            }
318        }
319        if unprotected_branch_set.is_empty() {
320            // Aliases are only protected branches, do nothing
321            return Ok(());
322        }
323        let branches: Vec<String> = unprotected_branch_set
324            .iter()
325            .map(|x| x.to_string())
326            .collect();
327        let selected_branches: Vec<_> = self
328            .ui
329            .select_identical_branches_to_delete(&branches)
330            .iter()
331            .map(|x| x.to_string())
332            .collect();
333        self.delete_branches(&selected_branches)?;
334        Ok(())
335    }
336
337    pub fn delete_identical_branches(&self) -> Result<(), AppError> {
338        // Create a hashmap sha1 => set(branches)
339        let mut branches_for_sha1: HashMap<String, HashSet<String>> = HashMap::new();
340
341        match self.repo.list_branches_with_sha1s() {
342            Ok(x) => x,
343            Err(x) => {
344                self.ui.log_error("Failed to list branches");
345                return Err(AppError::Git(x));
346            }
347        }
348        .iter()
349        .for_each(|(branch, sha1)| {
350            let branch_set = branches_for_sha1
351                .entry(sha1.to_string())
352                .or_insert_with(HashSet::<String>::new);
353            branch_set.insert(branch.to_string());
354        });
355
356        // Delete identical branches if there are more than one for the same sha1
357        for (sha1, branch_set) in branches_for_sha1 {
358            if branch_set.len() == 1 {
359                continue;
360            }
361            if let Err(x) = self.do_delete_identical_branches(&sha1, &branch_set) {
362                self.ui.log_error("Failed to list branches");
363                return Err(x);
364            }
365        }
366
367        Ok(())
368    }
369
370    pub fn safe_delete_branch(&self, branch: &str) -> Result<(), AppError> {
371        // A branch is only safe to delete if at least another branch contains it
372        let contained_in = self.repo.list_branches_containing(branch).unwrap();
373        if contained_in.len() < 2 {
374            self.ui.log_error(&format!(
375                "Not deleting {}, no other branches contain it",
376                branch
377            ));
378            return Err(AppError::UnsafeDelete);
379        }
380        self.repo.delete_branch(branch)?;
381        Ok(())
382    }
383
384    pub fn add_default_branch_to_protected_branches(&mut self) -> Result<(), AppError> {
385        let default_branch = match self.get_default_branch()? {
386            Some(x) => x,
387            None => {
388                if self.fetch {
389                    self.find_default_branch_from_git()?
390                } else {
391                    self.find_default_branch_from_user()?
392                }
393            }
394        };
395        self.protected_branches.insert(default_branch);
396        Ok(())
397    }
398
399    pub fn run(&mut self) -> Result<(), AppError> {
400        self.add_default_branch_to_protected_branches()?;
401        if self.fetch {
402            self.fetch_changes()?;
403        }
404
405        self.update_tracking_branches()?;
406        self.delete_identical_branches()?;
407        self.remove_merged_branches()?;
408        Ok(())
409    }
410}
411
412pub fn run(args: CliArgs, dir: &str) -> i32 {
413    let ui: Box<dyn AppUi> = match args.yes {
414        false => Box::new(InteractiveAppUi {}),
415        true => Box::new(BatchAppUi {}),
416    };
417    let mut app = App::new(&args, ui, dir);
418
419    if !app.is_working_tree_clean() {
420        return 1;
421    }
422
423    match app.run() {
424        Ok(()) => 0,
425        Err(_) => 1,
426    }
427}