├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── install.sh ├── src ├── branches.rs ├── cli.rs ├── commands.rs ├── error.rs ├── lib.rs ├── main.rs └── options.rs └── tests ├── deletion.rs ├── local.rs ├── remote.rs ├── support.rs ├── tests.rs └── utility.rs /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled files 2 | *.o 3 | *.so 4 | *.rlib 5 | *.dll 6 | 7 | # Executables 8 | *.exe 9 | 10 | # Generated by Cargo 11 | /target/ 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: rust 2 | rust: 3 | - stable 4 | - beta 5 | - nightly 6 | before_script: 7 | - git config --global user.email "user@example.com" 8 | - git config --global user.name "Example User" 9 | matrix: 10 | allow_failures: 11 | - rust: nightly 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.8.0 2 | 3 | ### Changes 4 | - Don't delete unpushed branches by default - by @tpilewicz 5 | 6 | ## 0.7.0 7 | 8 | ### Added 9 | - Windows support - by @jsinger67 10 | 11 | ## 0.6.0 12 | 13 | ### Changes 14 | - `main` is now the default branch 15 | 16 | ### Added 17 | - A changelog! 18 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "aho-corasick" 7 | version = "0.7.20" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "ansi_term" 16 | version = "0.11.0" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b" 19 | dependencies = [ 20 | "winapi", 21 | ] 22 | 23 | [[package]] 24 | name = "atty" 25 | version = "0.2.14" 26 | source = "registry+https://github.com/rust-lang/crates.io-index" 27 | checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" 28 | dependencies = [ 29 | "hermit-abi", 30 | "libc", 31 | "winapi", 32 | ] 33 | 34 | [[package]] 35 | name = "bitflags" 36 | version = "1.2.1" 37 | source = "registry+https://github.com/rust-lang/crates.io-index" 38 | checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" 39 | 40 | [[package]] 41 | name = "clap" 42 | version = "2.33.1" 43 | source = "registry+https://github.com/rust-lang/crates.io-index" 44 | checksum = "bdfa80d47f954d53a35a64987ca1422f495b8d6483c0fe9f7117b36c2a792129" 45 | dependencies = [ 46 | "ansi_term", 47 | "atty", 48 | "bitflags", 49 | "strsim", 50 | "textwrap", 51 | "unicode-width", 52 | "vec_map", 53 | ] 54 | 55 | [[package]] 56 | name = "fuchsia-cprng" 57 | version = "0.1.1" 58 | source = "registry+https://github.com/rust-lang/crates.io-index" 59 | checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" 60 | 61 | [[package]] 62 | name = "git-clean" 63 | version = "0.8.0" 64 | dependencies = [ 65 | "clap", 66 | "regex", 67 | "tempdir", 68 | ] 69 | 70 | [[package]] 71 | name = "hermit-abi" 72 | version = "0.1.13" 73 | source = "registry+https://github.com/rust-lang/crates.io-index" 74 | checksum = "91780f809e750b0a89f5544be56617ff6b1227ee485bcb06ebe10cdf89bd3b71" 75 | dependencies = [ 76 | "libc", 77 | ] 78 | 79 | [[package]] 80 | name = "libc" 81 | version = "0.2.71" 82 | source = "registry+https://github.com/rust-lang/crates.io-index" 83 | checksum = "9457b06509d27052635f90d6466700c65095fdf75409b3fbdd903e988b886f49" 84 | 85 | [[package]] 86 | name = "memchr" 87 | version = "2.5.0" 88 | source = "registry+https://github.com/rust-lang/crates.io-index" 89 | checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" 90 | 91 | [[package]] 92 | name = "rand" 93 | version = "0.4.6" 94 | source = "registry+https://github.com/rust-lang/crates.io-index" 95 | checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293" 96 | dependencies = [ 97 | "fuchsia-cprng", 98 | "libc", 99 | "rand_core 0.3.1", 100 | "rdrand", 101 | "winapi", 102 | ] 103 | 104 | [[package]] 105 | name = "rand_core" 106 | version = "0.3.1" 107 | source = "registry+https://github.com/rust-lang/crates.io-index" 108 | checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b" 109 | dependencies = [ 110 | "rand_core 0.4.2", 111 | ] 112 | 113 | [[package]] 114 | name = "rand_core" 115 | version = "0.4.2" 116 | source = "registry+https://github.com/rust-lang/crates.io-index" 117 | checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc" 118 | 119 | [[package]] 120 | name = "rdrand" 121 | version = "0.4.0" 122 | source = "registry+https://github.com/rust-lang/crates.io-index" 123 | checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2" 124 | dependencies = [ 125 | "rand_core 0.3.1", 126 | ] 127 | 128 | [[package]] 129 | name = "regex" 130 | version = "1.7.0" 131 | source = "registry+https://github.com/rust-lang/crates.io-index" 132 | checksum = "e076559ef8e241f2ae3479e36f97bd5741c0330689e217ad51ce2c76808b868a" 133 | dependencies = [ 134 | "aho-corasick", 135 | "memchr", 136 | "regex-syntax", 137 | ] 138 | 139 | [[package]] 140 | name = "regex-syntax" 141 | version = "0.6.28" 142 | source = "registry+https://github.com/rust-lang/crates.io-index" 143 | checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848" 144 | 145 | [[package]] 146 | name = "remove_dir_all" 147 | version = "0.5.2" 148 | source = "registry+https://github.com/rust-lang/crates.io-index" 149 | checksum = "4a83fa3702a688b9359eccba92d153ac33fd2e8462f9e0e3fdf155239ea7792e" 150 | dependencies = [ 151 | "winapi", 152 | ] 153 | 154 | [[package]] 155 | name = "strsim" 156 | version = "0.8.0" 157 | source = "registry+https://github.com/rust-lang/crates.io-index" 158 | checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" 159 | 160 | [[package]] 161 | name = "tempdir" 162 | version = "0.3.7" 163 | source = "registry+https://github.com/rust-lang/crates.io-index" 164 | checksum = "15f2b5fb00ccdf689e0149d1b1b3c03fead81c2b37735d812fa8bddbbf41b6d8" 165 | dependencies = [ 166 | "rand", 167 | "remove_dir_all", 168 | ] 169 | 170 | [[package]] 171 | name = "textwrap" 172 | version = "0.11.0" 173 | source = "registry+https://github.com/rust-lang/crates.io-index" 174 | checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" 175 | dependencies = [ 176 | "unicode-width", 177 | ] 178 | 179 | [[package]] 180 | name = "unicode-width" 181 | version = "0.1.7" 182 | source = "registry+https://github.com/rust-lang/crates.io-index" 183 | checksum = "caaa9d531767d1ff2150b9332433f32a24622147e5ebb1f26409d5da67afd479" 184 | 185 | [[package]] 186 | name = "vec_map" 187 | version = "0.8.2" 188 | source = "registry+https://github.com/rust-lang/crates.io-index" 189 | checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" 190 | 191 | [[package]] 192 | name = "winapi" 193 | version = "0.3.8" 194 | source = "registry+https://github.com/rust-lang/crates.io-index" 195 | checksum = "8093091eeb260906a183e6ae1abdba2ef5ef2257a21801128899c3fc699229c6" 196 | dependencies = [ 197 | "winapi-i686-pc-windows-gnu", 198 | "winapi-x86_64-pc-windows-gnu", 199 | ] 200 | 201 | [[package]] 202 | name = "winapi-i686-pc-windows-gnu" 203 | version = "0.4.0" 204 | source = "registry+https://github.com/rust-lang/crates.io-index" 205 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 206 | 207 | [[package]] 208 | name = "winapi-x86_64-pc-windows-gnu" 209 | version = "0.4.0" 210 | source = "registry+https://github.com/rust-lang/crates.io-index" 211 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 212 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "git-clean" 3 | version = "0.8.0" 4 | authors = ["Matt Casper "] 5 | license = "MIT" 6 | description = "A tool for cleaning old git branches." 7 | documentation = "https://github.com/mcasper/git-clean" 8 | homepage = "https://github.com/mcasper/git-clean" 9 | repository = "https://github.com/mcasper/git-clean" 10 | autotests = false 11 | 12 | [dependencies] 13 | clap = "2.33.1" 14 | regex = "1.6" 15 | 16 | [dev-dependencies] 17 | tempdir = "0.3" 18 | 19 | [[test]] 20 | name = "tests" 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Matt Casper 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # git-clean 2 | 3 | [![Build Status](https://travis-ci.org/mcasper/git-clean.svg?branch=master)](https://travis-ci.org/mcasper/git-clean) 4 | 5 | # The Problem 6 | 7 | If you work on one project for a long time, you're bound to amass a good number 8 | of branches. Deleting these branches locally whenever you're done with them 9 | gets annoying, and can cost you a lot of time in branch grooming, or trying to 10 | remember 'that command' to delete all merged branches locally. 11 | 12 | `git-clean` looks to remedy that. By running `git-clean`, you'll delete all 13 | your merged branches quickly and easily. 14 | 15 | # Other implementations 16 | 17 | There are a couple other tools out there like this, but they all fall short for 18 | me in some way. 19 | 20 | https://github.com/arc90/git-sweep 21 | 22 | This tool works great for smaller projects, but if you work on a large project 23 | with tens or hundreds of thousands of commits, and thousands of active 24 | branches, it stalls out. I've tried several times to get it to work on these 25 | larger projects, but I've never been able to. It also has troubles deleting 26 | branches locally if they've already been deleted in the remote. 27 | 28 | https://github.com/mloughran/git-clean 29 | 30 | This tool takes a slightly different approach, it will show you each branch 31 | sequentially and let you decide what to do with it. This might work great for 32 | some people, but I usually end up cleaning out my branches when the output of 33 | `git branch` becomes unmanagable, so I would rather batch delete all my merged 34 | branches in one go. 35 | 36 | https://github.com/dstnbrkr/git-trim 37 | 38 | This tool does something reminiscent of interactive rebasing, it will display 39 | _all_ of your branches in your text editor, let you choose which ones you want 40 | to delete, and deletes them upon saving. My problems with this are: It's a 41 | manual process - and, - It doesn't only display merged branches, meaning that 42 | you could delete branches that have valuable work on it. 43 | 44 | # Advantages to this project 45 | 46 | - Fast 47 | 48 | This project is written in Rust, which is [really stinkin 49 | fast](http://benchmarksgame.alioth.debian.org/u64q/rust.html). It takes about 50 | 1.8 seconds to delete 100+ branches, and most of that is network time. 51 | `./target/release/git-clean 0.07s user 0.08s system 8% cpu 1.837 total` 52 | 53 | - Batch operations 54 | 55 | It deletes your branches in bulk, no stepping through branches or selecting 56 | what branches you want gone. It assumes you want to delete all branches that 57 | are even with your base branch. 58 | 59 | - Deletes local and remote 60 | 61 | It deletes both local and remote branches, and handles the errors if the remote 62 | is already deleted. 63 | 64 | - Only presents merged branches 65 | 66 | There's no possibility of deleting branches with valuable work on them, as it 67 | only deletes branches that are even with the base branch you specify (defaults 68 | to main). 69 | 70 | - Handles branches squashed by Github 71 | 72 | Github recently introduced the ability to squash your merges from the Github 73 | UI, which is a really handy tool to avoid manually rebasing all the time. 74 | `git-clean` knows how to recognize branches that have been squashed by Github, 75 | and will make sure they get cleaned out of your local repo. 76 | 77 | # Assumptions 78 | 79 | This tool assumes (but will also check) that your `git` is properly configured 80 | to push and pull from the current repository. `git-clean` should be run from 81 | the directory that holds the `.git` directory you care about. 82 | 83 | This tool will run the `git` commands `branch`, `rev-parse`, `remote`, `pull`, 84 | and `push` on your system. `git push` will only ever be run as `git push 85 | --delete `, when deleting remote branches for you. If that 86 | isn't acceptable, use the `-l` flag to only delete branches locally. 87 | 88 | # Installation 89 | 90 | If you're a Rust developer, you can install using Cargo: 91 | 92 | ```shell 93 | cargo install git-clean 94 | ``` 95 | 96 | This was developed on Rust 1.14.0 stable, so if you're having issues with the 97 | compile/install step, make sure your Rust version is >= 1.14.0 stable. 98 | 99 | Be sure to add the installation path to your PATH variable. For me, it's 100 | downloaded to: 101 | 102 | ``` 103 | /Users/mattcasper/.multirust/toolchains/stable/cargo/bin/git-clean 104 | ``` 105 | 106 | If you're not a Rust developer, or just prefer another way, there's also 107 | a homebrew formula: 108 | 109 | ```shell 110 | brew tap mcasper/formulae 111 | brew install git-clean 112 | ``` 113 | 114 | Verify that it works!: 115 | 116 | ```shell 117 | $ git-clean -h 118 | USAGE: 119 | git-clean [FLAGS] [OPTIONS] 120 | 121 | FLAGS: 122 | -d, --delete-unpushed-branches Delete any local branch that is not present on the remote. Use this to speed up 123 | the checks if such branches should always be considered as merged 124 | -h, --help Prints help information 125 | -l, --locals Only delete local branches 126 | -r, --remotes Only delete remote branches 127 | -s, --squashes Check for squashes by finding branches incompatible with main 128 | -V, --version Prints version information 129 | -y, --yes Skip the check for deleting branches 130 | 131 | OPTIONS: 132 | -b, --branch Changes the base for merged branches (default is main) 133 | -i, --ignore ... Ignore given branch (repeat option for multiple branches) 134 | -R, --remote Changes the git remote used (default is origin) 135 | ``` 136 | 137 | # Updating 138 | 139 | If you're updating from an older version of git-clean, and using Cargo to 140 | install, just run the install command with `--force`: 141 | 142 | ```shell 143 | cargo install git-clean --force 144 | ``` 145 | 146 | # Use 147 | 148 | ## git-clean 149 | 150 | Lists all the branches to be deleted, and prompts you to confirm: 151 | 152 | ```shell 153 | $ git-clean 154 | The following branches will be deleted locally and remotely: 155 | branch1 156 | branch2 157 | branch3 158 | Continue? (Y/n) 159 | ``` 160 | 161 | If accepted, it will delete the listed branches both locally and remotely: 162 | 163 | ```shell 164 | Continue? (Y/n) y 165 | 166 | Remote: 167 | - [deleted] branch1 168 | branch2 was already deleted in the remote. 169 | 170 | Local: 171 | Deleted branch branch1 (was 3a9ea97). 172 | Deleted branch branch2 (was 3a9ea97). 173 | Deleted branch branch3 (was 3a9ea97). 174 | ``` 175 | 176 | Branches that are already deleted in the remote are filtered out from the 177 | output. 178 | 179 | It also offers several options for tweaking what branches get deleted, where. 180 | 181 | - `-l` and `-r` toggle deleting branches only locally or only remotely 182 | - `-R` changes the git remote that remote branches are deleted in 183 | - `-b` changes the base branch for finding merged branches to delete 184 | 185 | And other miscellaneous options: 186 | 187 | - `-y` overrides the delete branches check. Nice for automating workflows where 188 | you don't want to be prompted. 189 | 190 | # Contributions 191 | 192 | PRs and issues welcome! 193 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | rm -f /usr/local/bin/git-clean 4 | cargo build --release 5 | mv ./target/release/git-clean /usr/local/bin/git-clean 6 | -------------------------------------------------------------------------------- /src/branches.rs: -------------------------------------------------------------------------------- 1 | use commands::*; 2 | use error::Error; 3 | use options::*; 4 | use regex::Regex; 5 | use std::io::{stdin, stdout, Write}; 6 | 7 | pub const COLUMN_SPACER_LENGTH: usize = 30; 8 | 9 | #[derive(Debug)] 10 | pub struct Branches { 11 | pub string: String, 12 | pub vec: Vec, 13 | } 14 | 15 | impl Branches { 16 | pub fn new(branches: Vec) -> Branches { 17 | let trimmed_string = branches.join("\n").trim_end_matches('\n').into(); 18 | 19 | Branches { 20 | string: trimmed_string, 21 | vec: branches, 22 | } 23 | } 24 | 25 | pub fn print_warning_and_prompt(&self, delete_mode: &DeleteMode) -> Result<(), Error> { 26 | println!("{}", delete_mode.warning_message()); 27 | println!("{}", self.format_columns()); 28 | print!("Continue? (Y/n) "); 29 | stdout().flush()?; 30 | 31 | // Read the user's response on continuing 32 | let mut input = String::new(); 33 | stdin().read_line(&mut input)?; 34 | 35 | match input.to_lowercase().as_ref() { 36 | "y\n" | "y\r\n" | "yes\n" | "yes\r\n" | "\n" | "\r\n" => Ok(()), 37 | _ => Err(Error::ExitEarly), 38 | } 39 | } 40 | 41 | pub fn merged(options: &Options) -> Branches { 42 | let mut branches: Vec = vec![]; 43 | println!("Updating remote {}", options.remote); 44 | run_command_with_no_output(&["git", "remote", "update", &options.remote, "--prune"]); 45 | 46 | let merged_branches_regex = format!("^\\*?\\s*{}$", options.base_branch); 47 | let merged_branches_filter = Regex::new(&merged_branches_regex).unwrap(); 48 | let merged_branches_cmd = run_command(&["git", "branch", "--merged"]); 49 | let merged_branches_output = std::str::from_utf8(&merged_branches_cmd.stdout).unwrap(); 50 | 51 | let merged_branches = 52 | merged_branches_output 53 | .lines() 54 | .fold(Vec::::new(), |mut acc, line| { 55 | if !merged_branches_filter.is_match(line) { 56 | acc.push(line.trim().to_string()); 57 | } 58 | acc 59 | }); 60 | 61 | let local_branches_regex = format!("^\\*?\\s*{}$", options.base_branch); 62 | let local_branches_filter = Regex::new(&local_branches_regex).unwrap(); 63 | let local_branches_cmd = run_command(&["git", "branch"]); 64 | let local_branches_output = std::str::from_utf8(&local_branches_cmd.stdout).unwrap(); 65 | 66 | let local_branches = local_branches_output 67 | .lines() 68 | .fold(Vec::::new(), |mut acc, line| { 69 | if !local_branches_filter.is_match(line) { 70 | acc.push(line.trim().to_string()); 71 | } 72 | acc 73 | }) 74 | .iter() 75 | .filter(|branch| !options.ignored_branches.contains(branch)) 76 | .cloned() 77 | .collect::>(); 78 | 79 | let remote_branches_regex = format!("\\b(HEAD|{})\\b", &options.base_branch); 80 | let remote_branches_filter = Regex::new(&remote_branches_regex).unwrap(); 81 | let remote_branches_cmd = run_command(&["git", "branch", "-r"]); 82 | let remote_branches_output = std::str::from_utf8(&remote_branches_cmd.stdout).unwrap(); 83 | 84 | let remote_branches = 85 | remote_branches_output 86 | .lines() 87 | .fold(Vec::::new(), |mut acc, line| { 88 | if !remote_branches_filter.is_match(line) { 89 | acc.push(line.trim().to_string()); 90 | } 91 | acc 92 | }); 93 | 94 | for branch in local_branches { 95 | // First check if the local branch doesn't exist in the remote, it's the cheapest and easiest 96 | // way to determine if we want to suggest to delete it. 97 | if options.delete_unpushed_branches 98 | && !remote_branches 99 | .iter() 100 | .any(|b: &String| *b == format!("{}/{}", &options.remote, branch)) 101 | { 102 | branches.push(branch.to_owned()); 103 | continue; 104 | } 105 | 106 | // If it does exist in the remote, check to see if it's listed in git branches --merged. If 107 | // it is, that means it wasn't merged using Github squashes, and we can suggest it. 108 | if merged_branches.iter().any(|b: &String| *b == branch) { 109 | branches.push(branch.to_owned()); 110 | continue; 111 | } 112 | 113 | // If neither of the above matched, merge main into the branch and see if it succeeds. 114 | // If it can't cleanly merge, then it has likely been merged with Github squashes, and we 115 | // can suggest it. 116 | if options.squashes { 117 | run_command(&["git", "checkout", &branch]); 118 | match run_command_with_status(&[ 119 | "git", 120 | "pull", 121 | "--ff-only", 122 | &options.remote, 123 | &options.base_branch, 124 | ]) { 125 | Ok(status) => { 126 | if !status.success() { 127 | println!("why"); 128 | branches.push(branch); 129 | } 130 | } 131 | Err(err) => { 132 | println!( 133 | "Encountered error trying to update branch {} with branch {}: {}", 134 | branch, options.base_branch, err 135 | ); 136 | continue; 137 | } 138 | } 139 | 140 | run_command(&["git", "reset", "--hard"]); 141 | run_command(&["git", "checkout", &options.base_branch]); 142 | } 143 | } 144 | 145 | // if deleted in remote, list 146 | // 147 | // g branch -d -r / 148 | // g branch -d 149 | 150 | Branches::new(branches) 151 | } 152 | 153 | fn format_columns(&self) -> String { 154 | // Covers the single column case 155 | if self.vec.len() < 26 { 156 | return self.string.clone(); 157 | } 158 | 159 | let col_count = { 160 | let total_cols = self.vec.len() / 25 + 1; 161 | ::std::cmp::min(total_cols, 3) 162 | }; 163 | 164 | let chunks = self.vec.chunks(col_count); 165 | let mut col_indices = [0; 3]; 166 | 167 | for i in 1..col_count { 168 | let index = i - 1; 169 | let largest_col_member = chunks 170 | .clone() 171 | .map(|chunk| { 172 | if let Some(branch) = chunk.get(index) { 173 | branch.len() 174 | } else { 175 | 0 176 | } 177 | }) 178 | .max() 179 | .unwrap(); 180 | let next_col_start = largest_col_member + COLUMN_SPACER_LENGTH; 181 | col_indices[i - 1] = next_col_start; 182 | } 183 | 184 | let rows: Vec = self 185 | .vec 186 | .chunks(col_count) 187 | .map(|chunk| make_row(chunk, &col_indices)) 188 | .collect(); 189 | 190 | rows.join("\n").trim().to_owned() 191 | } 192 | 193 | pub fn delete(&self, options: &Options) -> String { 194 | match options.delete_mode { 195 | DeleteMode::Local => delete_local_branches(self), 196 | DeleteMode::Remote => delete_remote_branches(self, options), 197 | DeleteMode::Both => { 198 | let local_output = delete_local_branches(self); 199 | let remote_output = delete_remote_branches(self, options); 200 | [ 201 | "Remote:".to_owned(), 202 | remote_output, 203 | "\nLocal:".to_owned(), 204 | local_output, 205 | ] 206 | .join("\n") 207 | } 208 | } 209 | } 210 | } 211 | 212 | fn make_row(chunks: &[String], col_indices: &[usize]) -> String { 213 | match chunks.len() { 214 | 1 => chunks[0].clone(), 215 | 2 => { 216 | format!( 217 | "{b1:0$}{b2}", 218 | col_indices[0], 219 | b1 = chunks[0], 220 | b2 = chunks[1] 221 | ) 222 | } 223 | 3 => { 224 | format!( 225 | "{b1:0$}{b2:1$}{b3}", 226 | col_indices[0], 227 | col_indices[1], 228 | b1 = chunks[0], 229 | b2 = chunks[1], 230 | b3 = chunks[2] 231 | ) 232 | } 233 | _ => unreachable!("This code should never be reached!"), 234 | } 235 | } 236 | 237 | #[cfg(test)] 238 | mod test { 239 | use super::Branches; 240 | 241 | #[test] 242 | fn test_branches_new() { 243 | let input = vec!["branch1".to_owned(), "branch2".to_owned()]; 244 | let branches = Branches::new(input); 245 | 246 | assert_eq!("branch1\nbranch2".to_owned(), branches.string); 247 | assert_eq!( 248 | vec!["branch1".to_owned(), "branch2".to_owned()], 249 | branches.vec 250 | ); 251 | } 252 | 253 | #[test] 254 | fn test_format_single_column() { 255 | let mut input = vec![]; 256 | for _ in 0..24 { 257 | input.push("branch".to_owned()) 258 | } 259 | 260 | let branches = Branches::new(input); 261 | 262 | let expected = "\ 263 | branch 264 | branch 265 | branch 266 | branch 267 | branch 268 | branch 269 | branch 270 | branch 271 | branch 272 | branch 273 | \ 274 | branch 275 | branch 276 | branch 277 | branch 278 | branch 279 | branch 280 | branch 281 | branch 282 | branch 283 | branch 284 | \ 285 | branch 286 | branch 287 | branch 288 | branch"; 289 | 290 | assert_eq!(expected, branches.format_columns()); 291 | } 292 | 293 | #[test] 294 | fn test_format_two_columns() { 295 | let mut input = vec![]; 296 | for _ in 0..26 { 297 | input.push("branch".to_owned()) 298 | } 299 | 300 | let branches = Branches::new(input); 301 | 302 | let expected = "\ 303 | branch branch 304 | branch \ 305 | branch 306 | branch branch 307 | branch \ 308 | branch 309 | branch branch 310 | branch \ 311 | branch 312 | branch branch 313 | branch \ 314 | branch 315 | branch branch 316 | branch \ 317 | branch 318 | branch branch 319 | branch \ 320 | branch 321 | branch branch"; 322 | 323 | assert_eq!(expected, branches.format_columns()); 324 | } 325 | 326 | #[test] 327 | fn test_format_three_columns() { 328 | let mut input = vec![]; 329 | for _ in 0..51 { 330 | input.push("branch".to_owned()) 331 | } 332 | 333 | let branches = Branches::new(input); 334 | 335 | let expected = "\ 336 | branch branch \ 337 | branch 338 | branch branch \ 339 | branch 340 | branch branch \ 341 | branch 342 | branch branch \ 343 | branch 344 | branch branch \ 345 | branch 346 | branch branch \ 347 | branch 348 | branch branch \ 349 | branch 350 | branch branch \ 351 | branch 352 | branch branch \ 353 | branch 354 | branch branch \ 355 | branch 356 | branch branch \ 357 | branch 358 | branch branch \ 359 | branch 360 | branch branch \ 361 | branch 362 | branch branch \ 363 | branch 364 | branch branch \ 365 | branch 366 | branch branch \ 367 | branch 368 | branch branch \ 369 | branch"; 370 | 371 | assert_eq!(expected, branches.format_columns()); 372 | } 373 | 374 | #[test] 375 | fn test_format_maxes_at_three_columns() { 376 | let mut input = vec![]; 377 | for _ in 0..76 { 378 | input.push("branch".to_owned()) 379 | } 380 | 381 | let branches = Branches::new(input); 382 | 383 | let expected = "\ 384 | branch branch \ 385 | branch 386 | branch branch \ 387 | branch 388 | branch branch \ 389 | branch 390 | branch branch \ 391 | branch 392 | branch branch \ 393 | branch 394 | branch branch \ 395 | branch 396 | branch branch \ 397 | branch 398 | branch branch \ 399 | branch 400 | branch branch \ 401 | branch 402 | branch branch \ 403 | branch 404 | branch branch \ 405 | branch 406 | branch branch \ 407 | branch 408 | branch branch \ 409 | branch 410 | branch branch \ 411 | branch 412 | branch branch \ 413 | branch 414 | branch branch \ 415 | branch 416 | branch branch \ 417 | branch 418 | branch branch \ 419 | branch 420 | branch branch \ 421 | branch 422 | branch branch \ 423 | branch 424 | branch branch \ 425 | branch 426 | branch branch \ 427 | branch 428 | branch branch \ 429 | branch 430 | branch branch \ 431 | branch 432 | branch branch \ 433 | branch 434 | branch"; 435 | 436 | assert_eq!(expected, branches.format_columns()); 437 | } 438 | 439 | #[test] 440 | fn test_branches_of_different_lengths() { 441 | let mut input = vec![]; 442 | for (i, _) in (0..26).enumerate() { 443 | input.push(format!("branch{}", i)) 444 | } 445 | 446 | let branches = Branches::new(input); 447 | 448 | let expected = "\ 449 | branch0 branch1 450 | branch2 \ 451 | branch3 452 | branch4 branch5 453 | branch6 \ 454 | branch7 455 | branch8 branch9 456 | branch10 \ 457 | branch11 458 | branch12 branch13 459 | branch14 \ 460 | branch15 461 | branch16 branch17 462 | branch18 \ 463 | branch19 464 | branch20 branch21 465 | branch22 \ 466 | branch23 467 | branch24 branch25"; 468 | assert_eq!(expected, branches.format_columns()); 469 | } 470 | 471 | #[test] 472 | fn test_branches_of_bigger_lengths() { 473 | let mut input = vec!["really_long_branch_name".to_owned(), "branch-1".to_owned()]; 474 | for (i, _) in (0..26).enumerate() { 475 | input.push(format!("branch{}", i)); 476 | } 477 | 478 | let branches = Branches::new(input); 479 | 480 | let expected = "\ 481 | really_long_branch_name branch-1 482 | branch0 \ 483 | branch1 484 | branch2 branch3 485 | branch4 \ 486 | branch5 487 | branch6 branch7 488 | branch8 \ 489 | branch9 490 | branch10 branch11 491 | branch12 \ 492 | branch13 493 | branch14 branch15 494 | branch16 \ 495 | branch17 496 | branch18 branch19 497 | branch20 \ 498 | branch21 499 | branch22 branch23 500 | branch24 \ 501 | branch25"; 502 | assert_eq!(expected, branches.format_columns()); 503 | } 504 | 505 | #[test] 506 | fn test_long_branches_with_three_columns() { 507 | let mut input = vec![ 508 | "really_long_branch_name".to_owned(), 509 | "branch".to_owned(), 510 | "branch".to_owned(), 511 | "branch".to_owned(), 512 | "really_long_middle_col".to_owned(), 513 | "branch".to_owned(), 514 | ]; 515 | for i in 0..45 { 516 | input.push(format!("branch{}", i)); 517 | } 518 | 519 | let branches = Branches::new(input); 520 | 521 | let expected = "\ 522 | really_long_branch_name branch branch 523 | branch really_long_middle_col branch 524 | branch0 branch1 branch2 525 | branch3 branch4 branch5 526 | branch6 branch7 branch8 527 | branch9 branch10 branch11 528 | branch12 branch13 branch14 529 | branch15 branch16 branch17 530 | branch18 branch19 branch20 531 | branch21 branch22 branch23 532 | branch24 branch25 branch26 533 | branch27 branch28 branch29 534 | branch30 branch31 branch32 535 | branch33 branch34 branch35 536 | branch36 branch37 branch38 537 | branch39 branch40 branch41 538 | branch42 branch43 branch44"; 539 | assert_eq!(expected, branches.format_columns()); 540 | } 541 | } 542 | -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use clap::{App, Arg}; 2 | 3 | const VERSION: &str = env!("CARGO_PKG_VERSION"); 4 | 5 | pub fn build_cli() -> App<'static, 'static> { 6 | App::new("git-clean") 7 | .version(VERSION) 8 | .about("A tool for cleaning old git branches.") 9 | .arg( 10 | Arg::with_name("locals") 11 | .short("l") 12 | .long("locals") 13 | .help("Only delete local branches") 14 | .takes_value(false), 15 | ) 16 | .arg( 17 | Arg::with_name("remotes") 18 | .short("r") 19 | .long("remotes") 20 | .help("Only delete remote branches") 21 | .takes_value(false), 22 | ) 23 | .arg( 24 | Arg::with_name("yes") 25 | .short("y") 26 | .long("yes") 27 | .help("Skip the check for deleting branches") 28 | .takes_value(false), 29 | ) 30 | .arg( 31 | Arg::with_name("squashes") 32 | .short("s") 33 | .long("squashes") 34 | .help("Check for squashes by finding branches incompatible with main") 35 | .takes_value(false), 36 | ) 37 | .arg( 38 | Arg::with_name("delete-unpushed-branches") 39 | .short("d") 40 | .long("delete-unpushed-branches") 41 | .help("Delete any local branch that is not present on the remote. Use this to speed up the checks if such branches should always be considered as merged") 42 | .takes_value(false), 43 | ) 44 | .arg( 45 | Arg::with_name("remote") 46 | .short("R") 47 | .long("remote") 48 | .help("Changes the git remote used (default is origin)") 49 | .takes_value(true), 50 | ) 51 | .arg( 52 | Arg::with_name("branch") 53 | .short("b") 54 | .long("branch") 55 | .help("Changes the base for merged branches (default is main)") 56 | .takes_value(true), 57 | ) 58 | .arg( 59 | Arg::with_name("ignore") 60 | .short("i") 61 | .long("ignore") 62 | .help("Ignore given branch (repeat option for multiple branches)") 63 | .takes_value(true) 64 | .multiple(true), 65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /src/commands.rs: -------------------------------------------------------------------------------- 1 | use std::collections::BTreeSet; 2 | use std::io::Error as IOError; 3 | use std::process::{Command, ExitStatus, Output, Stdio}; 4 | 5 | use branches::Branches; 6 | use error::Error; 7 | use options::Options; 8 | 9 | pub fn run_command_with_no_output(args: &[&str]) { 10 | Command::new(args[0]) 11 | .args(&args[1..]) 12 | .stdin(Stdio::null()) 13 | .stdout(Stdio::null()) 14 | .stderr(Stdio::null()) 15 | .output() 16 | .unwrap_or_else(|e| panic!("Error with command: {}", e)); 17 | } 18 | 19 | pub fn output(args: &[&str]) -> String { 20 | let result = run_command(args); 21 | String::from_utf8(result.stdout).unwrap().trim().to_owned() 22 | } 23 | 24 | pub fn run_command(args: &[&str]) -> Output { 25 | run_command_with_result(args).unwrap_or_else(|e| panic!("Error with command: {}", e)) 26 | } 27 | 28 | pub fn run_command_with_result(args: &[&str]) -> Result { 29 | Command::new(args[0]).args(&args[1..]).output() 30 | } 31 | 32 | pub fn run_command_with_status(args: &[&str]) -> Result { 33 | Command::new(args[0]) 34 | .args(&args[1..]) 35 | .stdin(Stdio::null()) 36 | .stdout(Stdio::null()) 37 | .stderr(Stdio::null()) 38 | .status() 39 | } 40 | 41 | pub fn validate_git_installation() -> Result<(), Error> { 42 | match Command::new("git").output() { 43 | Ok(_) => Ok(()), 44 | Err(_) => Err(Error::GitInstallation), 45 | } 46 | } 47 | 48 | pub fn delete_local_branches(branches: &Branches) -> String { 49 | // https://git-scm.com/docs/git-branch 50 | // With a -d or -D option, will be deleted. You may specify more than one branch 51 | // for deletion. 52 | // 53 | // So we can work without xargs. 54 | if branches.vec.is_empty() { 55 | String::default() 56 | } else { 57 | let delete_branches_args = 58 | branches 59 | .vec 60 | .iter() 61 | .fold(vec!["git", "branch", "-D"], |mut acc, b| { 62 | acc.push(b); 63 | acc 64 | }); 65 | let delete_branches_cmd = run_command(&delete_branches_args); 66 | String::from_utf8(delete_branches_cmd.stdout).unwrap() 67 | } 68 | } 69 | 70 | pub fn delete_remote_branches(branches: &Branches, options: &Options) -> String { 71 | let remote_branches_cmd = run_command(&["git", "branch", "-r"]); 72 | 73 | let s = String::from_utf8(remote_branches_cmd.stdout).unwrap(); 74 | let all_remote_branches = s.split('\n').collect::>(); 75 | let origin_for_trim = &format!("{}/", &options.remote)[..]; 76 | let b_tree_remotes = all_remote_branches 77 | .iter() 78 | .map(|b| b.trim().trim_start_matches(origin_for_trim).to_owned()) 79 | .collect::>(); 80 | 81 | let mut b_tree_branches = BTreeSet::new(); 82 | 83 | for branch in branches.vec.clone() { 84 | b_tree_branches.insert(branch); 85 | } 86 | 87 | let intersection: Vec<_> = b_tree_remotes 88 | .intersection(&b_tree_branches) 89 | .cloned() 90 | .collect(); 91 | 92 | let stderr = if intersection.is_empty() { 93 | String::default() 94 | } else { 95 | let delete_branches_args = intersection.iter().fold( 96 | vec!["git", "push", &options.remote, "--delete"], 97 | |mut acc, b| { 98 | acc.push(b); 99 | acc 100 | }, 101 | ); 102 | let delete_remote_branches_cmd = run_command(&delete_branches_args); 103 | String::from_utf8(delete_remote_branches_cmd.stderr).unwrap() 104 | }; 105 | 106 | // Everything is written to stderr, so we need to process that 107 | let split = stderr.split('\n'); 108 | let vec: Vec<&str> = split.collect(); 109 | let mut output = vec![]; 110 | for s in vec { 111 | if s.contains("error: unable to delete '") { 112 | let branch = s 113 | .trim_start_matches("error: unable to delete '") 114 | .trim_end_matches("': remote ref does not exist"); 115 | 116 | output.push(branch.to_owned() + " was already deleted in the remote."); 117 | } else if s.contains(" - [deleted]") { 118 | output.push(s.to_owned()); 119 | } 120 | } 121 | 122 | output.join("\n") 123 | } 124 | 125 | #[cfg(test)] 126 | mod test { 127 | 128 | use regex::Regex; 129 | 130 | // `spawn_piped` was removed so this test is somewhat outdated. 131 | // It now tests the match operation for which `grep` was used before. 132 | #[test] 133 | fn test_spawn_piped() { 134 | let echo = Regex::new("foo\n").unwrap(); 135 | assert_eq!( 136 | echo.captures_iter("foo\nbar\nbaz") 137 | .fold(String::new(), |mut acc, e| { 138 | acc.push_str(&e[0]); 139 | acc 140 | }), 141 | "foo\n" 142 | ); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error as StdError; 2 | use std::fmt::{Display, Error as FmtError, Formatter}; 3 | use std::io::Error as IoError; 4 | 5 | #[derive(Debug)] 6 | pub enum Error { 7 | GitInstallation, 8 | CurrentBranchInvalid, 9 | InvalidRemote, 10 | ExitEarly, 11 | Io(IoError), 12 | } 13 | 14 | use self::Error::*; 15 | 16 | impl StdError for Error { 17 | fn source(&self) -> Option<&(dyn StdError + 'static)> { 18 | match *self { 19 | Io(ref io_error) => Some(io_error), 20 | _ => None, 21 | } 22 | } 23 | } 24 | 25 | impl Display for Error { 26 | fn fmt(&self, f: &mut Formatter) -> Result<(), FmtError> { 27 | match *self { 28 | Io(ref io_error) => io_error.fmt(f), 29 | ExitEarly => Ok(()), 30 | GitInstallation => { 31 | write!(f, "Unable to execute 'git' on your machine, please make sure it's installed and on your PATH") 32 | } 33 | CurrentBranchInvalid => { 34 | write!( 35 | f, 36 | "Please make sure to run git-clean from your base branch (defaults to main)." 37 | ) 38 | } 39 | InvalidRemote => { 40 | write!(f, "That remote doesn't exist, please make sure to use a valid remote (defaults to origin).") 41 | } 42 | } 43 | } 44 | } 45 | 46 | impl From for Error { 47 | fn from(error: IoError) -> Error { 48 | Io(error) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![deny(warnings)] 2 | 3 | extern crate clap; 4 | 5 | extern crate regex; 6 | 7 | pub mod cli; 8 | 9 | use clap::ArgMatches; 10 | 11 | mod branches; 12 | use branches::Branches; 13 | 14 | mod commands; 15 | pub use commands::validate_git_installation; 16 | 17 | mod error; 18 | use error::Error; 19 | 20 | mod options; 21 | use options::Options; 22 | 23 | pub fn run(matches: &ArgMatches) -> Result<(), error::Error> { 24 | validate_git_installation()?; 25 | 26 | let options = Options::new(matches); 27 | options.validate()?; 28 | 29 | let branches = Branches::merged(&options); 30 | 31 | if branches.string.is_empty() { 32 | println!("No branches to delete, you're clean!"); 33 | return Ok(()); 34 | } 35 | 36 | if !matches.is_present("yes") { 37 | branches.print_warning_and_prompt(&options.delete_mode)?; 38 | } 39 | 40 | let msg = branches.delete(&options); 41 | println!("\n{}", msg); 42 | 43 | Ok(()) 44 | } 45 | 46 | pub fn print_and_exit(error: &Error) { 47 | println!("{}", error); 48 | std::process::exit(1); 49 | } 50 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![deny(warnings)] 2 | 3 | extern crate git_clean; 4 | 5 | use git_clean::*; 6 | 7 | fn main() { 8 | let matches = cli::build_cli().get_matches(); 9 | 10 | run(&matches).unwrap_or_else(|e| print_and_exit(&e)); 11 | } 12 | -------------------------------------------------------------------------------- /src/options.rs: -------------------------------------------------------------------------------- 1 | use clap::ArgMatches; 2 | use commands::{output, run_command}; 3 | use error::Error; 4 | use regex::Regex; 5 | 6 | const DEFAULT_REMOTE: &str = "origin"; 7 | const DEFAULT_BRANCH: &str = "main"; 8 | 9 | #[derive(Debug)] 10 | pub enum DeleteMode { 11 | Local, 12 | Remote, 13 | Both, 14 | } 15 | 16 | pub use self::DeleteMode::*; 17 | 18 | impl DeleteMode { 19 | pub fn new(opts: &ArgMatches) -> DeleteMode { 20 | if opts.is_present("locals") { 21 | Local 22 | } else if opts.is_present("remotes") { 23 | Remote 24 | } else { 25 | Both 26 | } 27 | } 28 | 29 | pub fn warning_message(&self) -> String { 30 | let source = match *self { 31 | Local => "locally:", 32 | Remote => "remotely:", 33 | Both => "locally and remotely:", 34 | }; 35 | format!("The following branches will be deleted {}", source) 36 | } 37 | } 38 | 39 | pub struct Options { 40 | pub remote: String, 41 | pub base_branch: String, 42 | pub squashes: bool, 43 | pub delete_unpushed_branches: bool, 44 | pub ignored_branches: Vec, 45 | pub delete_mode: DeleteMode, 46 | } 47 | 48 | impl Options { 49 | pub fn new(opts: &ArgMatches) -> Options { 50 | let default_ignored = Vec::new(); 51 | let ignored = opts 52 | .values_of("ignore") 53 | .map(|i| i.map(|v| v.to_owned()).collect::>()) 54 | .unwrap_or(default_ignored); 55 | Options { 56 | remote: opts.value_of("remote").unwrap_or(DEFAULT_REMOTE).into(), 57 | base_branch: opts.value_of("branch").unwrap_or(DEFAULT_BRANCH).into(), 58 | ignored_branches: ignored, 59 | squashes: opts.is_present("squashes"), 60 | delete_unpushed_branches: opts.is_present("delete-unpushed-branches"), 61 | delete_mode: DeleteMode::new(opts), 62 | } 63 | } 64 | 65 | pub fn validate(&self) -> Result<(), Error> { 66 | self.validate_base_branch()?; 67 | self.validate_remote()?; 68 | Ok(()) 69 | } 70 | 71 | fn validate_base_branch(&self) -> Result<(), Error> { 72 | let current_branch = output(&["git", "rev-parse", "--abbrev-ref", "HEAD"]); 73 | 74 | if current_branch != self.base_branch { 75 | return Err(Error::CurrentBranchInvalid); 76 | }; 77 | 78 | Ok(()) 79 | } 80 | 81 | fn validate_remote(&self) -> Result<(), Error> { 82 | let remote_rx = Regex::new(&self.remote).unwrap(); 83 | let remotes = run_command(&["git", "remote"]); 84 | let remotes_output = std::str::from_utf8(&remotes.stdout).unwrap(); 85 | 86 | let remote_result = 87 | remote_rx 88 | .captures_iter(remotes_output) 89 | .fold(String::new(), |mut acc, e| { 90 | acc.push_str(&e[0]); 91 | acc 92 | }); 93 | 94 | if remote_result.is_empty() { 95 | return Err(Error::InvalidRemote); 96 | } 97 | 98 | Ok(()) 99 | } 100 | } 101 | 102 | #[cfg(test)] 103 | mod test { 104 | use super::{DeleteMode, Options}; 105 | use clap; 106 | use cli; 107 | 108 | // Helpers 109 | fn parse_args(args: Vec<&str>) -> clap::ArgMatches { 110 | cli::build_cli().get_matches_from(args) 111 | } 112 | 113 | // DeleteMode tests 114 | #[test] 115 | fn test_delete_mode_new() { 116 | let matches = parse_args(vec!["git-clean", "-l"]); 117 | 118 | match DeleteMode::new(&matches) { 119 | DeleteMode::Local => (), 120 | other @ _ => panic!("Expected a DeleteMode::Local, but found: {:?}", other), 121 | }; 122 | 123 | let matches = parse_args(vec!["git-clean", "-r"]); 124 | 125 | match DeleteMode::new(&matches) { 126 | DeleteMode::Remote => (), 127 | other @ _ => panic!("Expected a DeleteMode::Remote, but found: {:?}", other), 128 | }; 129 | 130 | let matches = parse_args(vec!["git-clean"]); 131 | 132 | match DeleteMode::new(&matches) { 133 | DeleteMode::Both => (), 134 | other @ _ => panic!("Expected a DeleteMode::Both, but found: {:?}", other), 135 | }; 136 | } 137 | 138 | #[test] 139 | fn test_delete_mode_warning_message() { 140 | assert_eq!( 141 | "The following branches will be deleted locally:", 142 | DeleteMode::Local.warning_message() 143 | ); 144 | assert_eq!( 145 | "The following branches will be deleted remotely:", 146 | DeleteMode::Remote.warning_message() 147 | ); 148 | assert_eq!( 149 | "The following branches will be deleted locally and remotely:", 150 | DeleteMode::Both.warning_message() 151 | ); 152 | } 153 | 154 | // Options tests 155 | #[test] 156 | fn test_git_options_new() { 157 | let matches = parse_args(vec!["git-clean"]); 158 | let git_options = Options::new(&matches); 159 | 160 | assert_eq!("main".to_owned(), git_options.base_branch); 161 | assert_eq!("origin".to_owned(), git_options.remote); 162 | 163 | let matches = parse_args(vec!["git-clean", "-b", "stable"]); 164 | let git_options = Options::new(&matches); 165 | 166 | assert_eq!("stable".to_owned(), git_options.base_branch); 167 | assert_eq!("origin".to_owned(), git_options.remote); 168 | 169 | let matches = parse_args(vec!["git-clean", "-R", "upstream"]); 170 | let git_options = Options::new(&matches); 171 | 172 | assert_eq!("main".to_owned(), git_options.base_branch); 173 | assert_eq!("upstream".to_owned(), git_options.remote); 174 | assert!(!git_options.squashes); 175 | assert!(!git_options.delete_unpushed_branches); 176 | 177 | let matches = parse_args(vec![ 178 | "git-clean", 179 | "-R", 180 | "upstream", 181 | "--squashes", 182 | "--delete-unpushed-branches", 183 | ]); 184 | let git_options = Options::new(&matches); 185 | 186 | assert!(git_options.squashes); 187 | assert!(git_options.delete_unpushed_branches); 188 | 189 | let matches = parse_args(vec![ 190 | "git-clean", 191 | "-i", 192 | "branch1", 193 | "-i", 194 | "branch2", 195 | "-i", 196 | "branch3", 197 | ]); 198 | let git_options = Options::new(&matches); 199 | 200 | assert_eq!( 201 | git_options.ignored_branches, 202 | vec!["branch1", "branch2", "branch3"] 203 | ); 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /tests/deletion.rs: -------------------------------------------------------------------------------- 1 | use support::project; 2 | 3 | macro_rules! touch_command { 4 | ($project:ident, $file_name:literal) => { 5 | if cfg!(windows) { 6 | format!( 7 | "cmd /c copy nul {}\\{}", 8 | $project.path().display(), 9 | $file_name 10 | ) 11 | } else { 12 | format!("touch {}", $file_name) 13 | } 14 | }; 15 | } 16 | 17 | #[test] 18 | fn test_git_clean_works_with_merged_branches() { 19 | let project = project("git-clean_squashed_merges").build().setup_remote(); 20 | 21 | let touch_command = touch_command!(project, "file2.txt"); 22 | 23 | project.batch_setup_commands(&[ 24 | "git checkout -b merged", 25 | &touch_command, 26 | "git add .", 27 | "git commit -am Merged", 28 | "git checkout main", 29 | "git merge merged", 30 | ]); 31 | 32 | let result = project.git_clean_command("-y").run(); 33 | 34 | assert!( 35 | result.is_success(), 36 | "{}", 37 | result.failure_message("command to succeed") 38 | ); 39 | assert!( 40 | result.stdout().contains("Deleted branch merged"), 41 | "{}", 42 | result.failure_message("command to delete merged") 43 | ); 44 | } 45 | 46 | #[test] 47 | fn test_git_clean_works_with_squashed_merges() { 48 | let project = project("git-clean_squashed_merges").build().setup_remote(); 49 | 50 | let touch_command = touch_command!(project, "file2.txt"); 51 | 52 | project.batch_setup_commands(&[ 53 | "git checkout -b squashed", 54 | &touch_command, 55 | "git add .", 56 | "git commit -am Squash", 57 | "git checkout main", 58 | "git merge --ff-only squashed", 59 | ]); 60 | 61 | let result = project.git_clean_command("-y").run(); 62 | 63 | assert!( 64 | result.is_success(), 65 | "{}", 66 | result.failure_message("command to succeed") 67 | ); 68 | assert!( 69 | result.stdout().contains("Deleted branch squashed"), 70 | "{}", 71 | result.failure_message("command to delete squashed") 72 | ); 73 | } 74 | 75 | fn git_clean_does_not_delete_branches_ahead_of_main(flags: &str) { 76 | let project = project("git-clean_branch_ahead").build().setup_remote(); 77 | 78 | let touch_command = touch_command!(project, "file2.txt"); 79 | 80 | project.batch_setup_commands(&[ 81 | "git checkout -b ahead", 82 | &touch_command, 83 | "git add .", 84 | "git commit -am Ahead", 85 | "git push origin HEAD", 86 | "git checkout main", 87 | ]); 88 | 89 | let result = project.git_clean_command(flags).run(); 90 | 91 | assert!( 92 | result.is_success(), 93 | "{}", 94 | result.failure_message("command to succeed") 95 | ); 96 | assert!( 97 | !result.stdout().contains("Deleted branch ahead"), 98 | "{}", 99 | result.failure_message("command not to delete ahead") 100 | ); 101 | } 102 | 103 | #[test] 104 | fn test_git_clean_does_not_delete_branches_ahead_of_main() { 105 | git_clean_does_not_delete_branches_ahead_of_main("-y") 106 | } 107 | 108 | #[test] 109 | fn test_git_clean_does_not_delete_branches_ahead_of_main_d_flag() { 110 | git_clean_does_not_delete_branches_ahead_of_main("-y -d") 111 | } 112 | 113 | fn git_clean_with_unpushed_ahead_branch(flags: &str, expect_branch_deleted: bool) { 114 | let project = project("git-clean_branch_ahead").build().setup_remote(); 115 | 116 | let touch_command = touch_command!(project, "file2.txt"); 117 | 118 | project.batch_setup_commands(&[ 119 | "git checkout -b ahead", 120 | &touch_command, 121 | "git add .", 122 | "git commit -am Ahead", 123 | "git checkout main", 124 | ]); 125 | 126 | let result = project.git_clean_command(flags).run(); 127 | 128 | assert!( 129 | result.is_success(), 130 | "{}", 131 | result.failure_message("command to succeed") 132 | ); 133 | if expect_branch_deleted { 134 | assert!( 135 | result.stdout().contains("Deleted branch ahead"), 136 | "{}", 137 | result.failure_message("command to delete ahead") 138 | ); 139 | } else { 140 | assert!( 141 | !result.stdout().contains("Deleted branch ahead"), 142 | "{}", 143 | result.failure_message("command not to delete ahead") 144 | ); 145 | } 146 | } 147 | 148 | #[test] 149 | fn test_git_clean_does_not_delete_unpushed_ahead_branch() { 150 | git_clean_with_unpushed_ahead_branch("-y", false) 151 | } 152 | 153 | #[test] 154 | fn test_git_clean_deletes_unpushed_ahead_branch() { 155 | git_clean_with_unpushed_ahead_branch("-y -d", true) 156 | } 157 | 158 | #[test] 159 | fn test_git_clean_works_with_squashes_with_flag() { 160 | let project = project("git-clean_github_squashes").build().setup_remote(); 161 | 162 | let touch_squash_command = touch_command!(project, "squash.txt"); 163 | let touch_new_command = touch_command!(project, "new.txt"); 164 | 165 | // Github squashes function basically like a normal squashed merge, it creates an entirely new 166 | // commit in which all your changes live. The biggest challenge of this is that your local 167 | // branch doesn't have any knowledge of this new commit. So if main gets ahead of your local 168 | // branch, git no longer is able to tell that branch has been merged. These commands simulate 169 | // this condition. 170 | project.batch_setup_commands(&[ 171 | "git checkout -b github_squash", 172 | &touch_squash_command, 173 | "git add .", 174 | "git commit -am Commit", 175 | "git push origin HEAD", 176 | "git checkout main", 177 | &touch_squash_command, 178 | "git add .", 179 | "git commit -am Squash", 180 | &touch_new_command, 181 | "git add .", 182 | "git commit -am Other", 183 | "git push origin HEAD", 184 | ]); 185 | 186 | let result = project.git_clean_command("-y --squashes").run(); 187 | 188 | assert!( 189 | result.is_success(), 190 | "{}", 191 | result.failure_message("command to succeed") 192 | ); 193 | assert!( 194 | result 195 | .stdout() 196 | .contains(" - [deleted] github_squash"), 197 | "{}", 198 | result.failure_message("command to delete github_squash in remote") 199 | ); 200 | assert!( 201 | result.stdout().contains("Deleted branch github_squash"), 202 | "{}", 203 | result.failure_message("command to delete github_squash locally") 204 | ); 205 | } 206 | 207 | #[test] 208 | fn test_git_clean_ignores_squashes_without_flag() { 209 | let project = project("git-clean_ignores_github_squashes") 210 | .build() 211 | .setup_remote(); 212 | 213 | let touch_squash_command = touch_command!(project, "squash.txt"); 214 | let touch_new_command = touch_command!(project, "new.txt"); 215 | 216 | // Github squashes function basically like a normal squashed merge, it creates an entirely new 217 | // commit in which all your changes live. The biggest challenge of this is that your local 218 | // branch doesn't have any knowledge of this new commit. So if main gets ahead of your local 219 | // branch, git no longer is able to tell that branch has been merged. These commands simulate 220 | // this condition. 221 | project.batch_setup_commands(&[ 222 | "git checkout -b github_squash", 223 | &touch_squash_command, 224 | "git add .", 225 | "git commit -am Commit", 226 | "git push origin HEAD", 227 | "git checkout main", 228 | &touch_squash_command, 229 | "git add .", 230 | "git commit -am Squash", 231 | &touch_new_command, 232 | "git add .", 233 | "git commit -am Other", 234 | "git push origin HEAD", 235 | ]); 236 | 237 | let result = project.git_clean_command("-y").run(); 238 | 239 | assert!( 240 | result.is_success(), 241 | "{}", 242 | result.failure_message("command to succeed") 243 | ); 244 | assert!( 245 | !result 246 | .stdout() 247 | .contains(" - [deleted] github_squash"), 248 | "{}", 249 | result.failure_message("command not to delete github_squash in remote") 250 | ); 251 | assert!( 252 | !result.stdout().contains("Deleted branch github_squash"), 253 | "{}", 254 | result.failure_message("command not to delete github_squash locally") 255 | ); 256 | } 257 | -------------------------------------------------------------------------------- /tests/local.rs: -------------------------------------------------------------------------------- 1 | use support::project; 2 | 3 | #[test] 4 | fn test_git_clean_removes_local_branches() { 5 | let project = project("git-clean_removes_local").build(); 6 | 7 | project.setup_command("git branch test1"); 8 | project.setup_command("git branch test2"); 9 | 10 | let verify = project.setup_command("git branch"); 11 | 12 | assert!( 13 | verify.stdout().contains("test1"), 14 | "{}", 15 | verify.failure_message("test1") 16 | ); 17 | assert!( 18 | verify.stdout().contains("test2"), 19 | "{}", 20 | verify.failure_message("test2") 21 | ); 22 | 23 | let result = project.git_clean_command("-y").run(); 24 | 25 | assert!( 26 | result.is_success(), 27 | "{}", 28 | result.failure_message("command to succeed") 29 | ); 30 | assert!( 31 | result.stdout().contains("Deleted branch test1"), 32 | "{}", 33 | result.failure_message("command to delete test1") 34 | ); 35 | assert!( 36 | result.stdout().contains("Deleted branch test2"), 37 | "{}", 38 | result.failure_message("command to delete test2") 39 | ); 40 | } 41 | 42 | #[test] 43 | fn test_git_clean_does_not_remove_ignored_local_branches() { 44 | let project = project("git-clean_removes_local").build(); 45 | 46 | project.setup_command("git branch test1"); 47 | project.setup_command("git branch test2"); 48 | 49 | let verify = project.setup_command("git branch"); 50 | 51 | assert!( 52 | verify.stdout().contains("test1"), 53 | "{}", 54 | verify.failure_message("test1") 55 | ); 56 | assert!( 57 | verify.stdout().contains("test2"), 58 | "{}", 59 | verify.failure_message("test2") 60 | ); 61 | 62 | let result = project.git_clean_command("-y -i test2").run(); 63 | 64 | assert!( 65 | result.is_success(), 66 | "{}", 67 | result.failure_message("command to succeed") 68 | ); 69 | assert!( 70 | result.stdout().contains("Deleted branch test1"), 71 | "{}", 72 | result.failure_message("command to delete test1") 73 | ); 74 | assert!( 75 | !result.stdout().contains("Deleted branch test2"), 76 | "{}", 77 | result.failure_message("command to delete test2") 78 | ); 79 | } 80 | 81 | #[test] 82 | fn test_git_clean_does_not_remove_list_of_ignored_local_branches() { 83 | let project = project("git-clean_removes_local").build(); 84 | 85 | project.setup_command("git branch test1"); 86 | project.setup_command("git branch test2"); 87 | project.setup_command("git branch test3"); 88 | 89 | let verify = project.setup_command("git branch"); 90 | 91 | assert!( 92 | verify.stdout().contains("test1"), 93 | "{}", 94 | verify.failure_message("test1") 95 | ); 96 | assert!( 97 | verify.stdout().contains("test2"), 98 | "{}", 99 | verify.failure_message("test2") 100 | ); 101 | assert!( 102 | verify.stdout().contains("test3"), 103 | "{}", 104 | verify.failure_message("test3") 105 | ); 106 | 107 | let result = project.git_clean_command("-y -i test1 -i test3").run(); 108 | 109 | assert!( 110 | result.is_success(), 111 | "{}", 112 | result.failure_message("command to succeed") 113 | ); 114 | assert!( 115 | !result.stdout().contains("Deleted branch test1"), 116 | "{}", 117 | result.failure_message("command to delete test1") 118 | ); 119 | assert!( 120 | result.stdout().contains("Deleted branch test2"), 121 | "{}", 122 | result.failure_message("command to delete test2") 123 | ); 124 | assert!( 125 | !result.stdout().contains("Deleted branch test3"), 126 | "{}", 127 | result.failure_message("command to delete test3") 128 | ); 129 | } 130 | -------------------------------------------------------------------------------- /tests/remote.rs: -------------------------------------------------------------------------------- 1 | use support::project; 2 | 3 | #[test] 4 | fn test_git_clean_removes_remote_branches() { 5 | let project = project("git-clean_removes_remote").build().setup_remote(); 6 | 7 | project.batch_setup_commands(&[ 8 | "git checkout -b test1", 9 | "git push origin HEAD", 10 | "git checkout -b test2", 11 | "git push origin HEAD", 12 | "git checkout main", 13 | ]); 14 | 15 | let verify = project.setup_command("git branch -r"); 16 | 17 | assert!( 18 | verify.stdout().contains("test1"), 19 | "{}", 20 | verify.failure_message("test1 to exist in remote") 21 | ); 22 | assert!( 23 | verify.stdout().contains("test2"), 24 | "{}", 25 | verify.failure_message("test2 to exist in remote") 26 | ); 27 | 28 | let result = project.git_clean_command("-y").run(); 29 | 30 | assert!( 31 | result.is_success(), 32 | "{}", 33 | result.failure_message("command to succeed") 34 | ); 35 | assert!( 36 | result 37 | .stdout() 38 | .contains(deleted_branch_output("test1").as_str()), 39 | "{}", 40 | result.failure_message("command to delete test1") 41 | ); 42 | assert!( 43 | result 44 | .stdout() 45 | .contains(deleted_branch_output("test2").as_str()), 46 | "{}", 47 | result.failure_message("command to delete test2") 48 | ); 49 | } 50 | 51 | #[test] 52 | fn test_git_clean_does_not_remove_ignored_remote_branches() { 53 | let project = project("git-clean_removes_remote").build().setup_remote(); 54 | 55 | project.batch_setup_commands(&[ 56 | "git checkout -b test1", 57 | "git push origin HEAD", 58 | "git checkout -b test2", 59 | "git push origin HEAD", 60 | "git checkout main", 61 | ]); 62 | 63 | let verify = project.setup_command("git branch -r"); 64 | 65 | assert!( 66 | verify.stdout().contains("test1"), 67 | "{}", 68 | verify.failure_message("test1 to exist in remote") 69 | ); 70 | assert!( 71 | verify.stdout().contains("test2"), 72 | "{}", 73 | verify.failure_message("test2 to exist in remote") 74 | ); 75 | 76 | let result = project.git_clean_command("-y -i test2").run(); 77 | 78 | assert!( 79 | result.is_success(), 80 | "{}", 81 | result.failure_message("command to succeed") 82 | ); 83 | assert!( 84 | result 85 | .stdout() 86 | .contains(deleted_branch_output("test1").as_str()), 87 | "{}", 88 | result.failure_message("command to delete test1") 89 | ); 90 | assert!( 91 | !result 92 | .stdout() 93 | .contains(deleted_branch_output("test2").as_str()), 94 | "{}", 95 | result.failure_message("command to delete test2") 96 | ); 97 | } 98 | 99 | fn deleted_branch_output(branch: &str) -> String { 100 | format!(" - [deleted] {}", branch) 101 | } 102 | -------------------------------------------------------------------------------- /tests/support.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | use std::process::{Command, Output}; 3 | use std::{env, str}; 4 | use tempdir::TempDir; 5 | 6 | macro_rules! touch_command { 7 | ($project:ident, $file_name:literal) => { 8 | if cfg!(windows) { 9 | format!( 10 | "cmd /c copy nul {}\\{}", 11 | $project.path().display(), 12 | $file_name 13 | ) 14 | } else { 15 | format!("touch {}", $file_name) 16 | } 17 | }; 18 | } 19 | 20 | pub fn project(name: &str) -> ProjectBuilder { 21 | ProjectBuilder::new(name) 22 | } 23 | 24 | pub struct ProjectBuilder { 25 | pub name: String, 26 | } 27 | 28 | impl ProjectBuilder { 29 | fn new(name: &str) -> Self { 30 | ProjectBuilder { name: name.into() } 31 | } 32 | 33 | pub fn build(self) -> Project { 34 | let work_dir = TempDir::new(&self.name).unwrap(); 35 | let remote_dir = TempDir::new(&format!("{}_remote", &self.name)).unwrap(); 36 | 37 | let project = Project { 38 | directory: work_dir, 39 | name: self.name, 40 | remote: remote_dir, 41 | }; 42 | 43 | let touch_command = touch_command!(project, "test_file.txt"); 44 | 45 | project.batch_setup_commands(&[ 46 | "git init", 47 | "git checkout -b main", 48 | "git config push.default matching", 49 | "git remote add origin remote", 50 | &touch_command, 51 | "git add .", 52 | "git commit -am Init", 53 | ]); 54 | 55 | project 56 | } 57 | } 58 | 59 | pub struct Project { 60 | directory: TempDir, 61 | pub name: String, 62 | remote: TempDir, 63 | } 64 | 65 | impl Project { 66 | pub fn setup_command(&self, command: &str) -> TestCommandResult { 67 | let command_pieces = command.split(' ').collect::>(); 68 | let result = TestCommand::new( 69 | &self.path(), 70 | command_pieces[1..].to_vec(), 71 | command_pieces[0], 72 | ) 73 | .run(); 74 | 75 | if !result.is_success() { 76 | panic!("{}", result.failure_message("setup command to succeed")) 77 | } 78 | 79 | result 80 | } 81 | 82 | pub fn remote_setup_command(&self, command: &str) -> TestCommandResult { 83 | let command_pieces = command.split(' ').collect::>(); 84 | let result = TestCommand::new( 85 | &self.remote_path(), 86 | command_pieces[1..].to_vec(), 87 | command_pieces[0], 88 | ) 89 | .run(); 90 | 91 | if !result.is_success() { 92 | panic!( 93 | "{}", 94 | result.failure_message("remote setup command to succeed") 95 | ) 96 | } 97 | 98 | result 99 | } 100 | 101 | pub fn batch_setup_commands(&self, commands: &[&str]) { 102 | for command in commands.iter() { 103 | self.setup_command(command); 104 | } 105 | } 106 | 107 | pub fn git_clean_command(&self, command: &str) -> TestCommand { 108 | let command_pieces = command.split(' ').collect::>(); 109 | TestCommand::new(&self.path(), command_pieces, path_to_git_clean()) 110 | } 111 | 112 | pub fn path(&self) -> PathBuf { 113 | self.directory.path().into() 114 | } 115 | 116 | fn remote_path(&self) -> PathBuf { 117 | self.remote.path().into() 118 | } 119 | 120 | pub fn setup_remote(self) -> Project { 121 | self.remote_setup_command("git init"); 122 | self.remote_setup_command("git checkout -b other"); 123 | 124 | self.setup_command(&format!( 125 | "git remote set-url origin {}", 126 | self.remote_path().display() 127 | )); 128 | self.setup_command("git push origin HEAD"); 129 | 130 | self 131 | } 132 | } 133 | 134 | pub struct TestCommand { 135 | pub path: PathBuf, 136 | args: Vec, 137 | envs: Vec<(String, String)>, 138 | top_level_command: String, 139 | } 140 | 141 | impl TestCommand { 142 | fn new>(path: &Path, args: Vec<&str>, top_level_command: S) -> Self { 143 | let owned_args = args 144 | .iter() 145 | .map(|arg| arg.to_owned().to_owned()) 146 | .collect::>(); 147 | 148 | TestCommand { 149 | path: path.into(), 150 | args: owned_args, 151 | envs: vec![], 152 | top_level_command: top_level_command.into(), 153 | } 154 | } 155 | 156 | pub fn env(mut self, key: &str, value: &str) -> TestCommand { 157 | self.envs.push((key.into(), value.into())); 158 | self 159 | } 160 | 161 | pub fn run(&self) -> TestCommandResult { 162 | let mut command = Command::new(&self.top_level_command); 163 | for &(ref k, ref v) in &self.envs { 164 | command.env(&k, &v); 165 | } 166 | let output = command 167 | .args(&self.args) 168 | .current_dir(&self.path) 169 | .output() 170 | .unwrap(); 171 | 172 | TestCommandResult { output: output } 173 | } 174 | } 175 | 176 | pub struct TestCommandResult { 177 | output: Output, 178 | } 179 | 180 | impl TestCommandResult { 181 | pub fn is_success(&self) -> bool { 182 | self.output.status.success() 183 | } 184 | 185 | pub fn stdout(&self) -> &str { 186 | str::from_utf8(&self.output.stdout).unwrap() 187 | } 188 | 189 | pub fn stderr(&self) -> &str { 190 | str::from_utf8(&self.output.stderr).unwrap() 191 | } 192 | 193 | pub fn failure_message(&self, expectation: &str) -> String { 194 | format!( 195 | "Expected {}, instead found\nstdout: {}\nstderr: {}\n", 196 | expectation, 197 | self.stdout(), 198 | self.stderr() 199 | ) 200 | } 201 | } 202 | 203 | fn path_to_git_clean() -> String { 204 | let path = Path::new(env!("CARGO_MANIFEST_DIR")) 205 | .join("target") 206 | .join("debug") 207 | .join(if cfg!(windows) { 208 | "git-clean.exe" 209 | } else { 210 | "git-clean" 211 | }) 212 | .to_str() 213 | .unwrap() 214 | .to_owned(); 215 | println!("Path is: {:?}", path); 216 | path 217 | } 218 | -------------------------------------------------------------------------------- /tests/tests.rs: -------------------------------------------------------------------------------- 1 | #![deny(warnings)] 2 | 3 | extern crate tempdir; 4 | 5 | // Module full of support functions and structs for integration tests 6 | mod support; 7 | 8 | // Actual integration tests 9 | mod deletion; 10 | mod local; 11 | mod remote; 12 | mod utility; 13 | -------------------------------------------------------------------------------- /tests/utility.rs: -------------------------------------------------------------------------------- 1 | use support::project; 2 | 3 | #[test] 4 | fn test_git_clean_checks_for_git_in_path() { 5 | let project = project("git-clean_removes").build(); 6 | 7 | let result = project.git_clean_command("-y").env("PATH", "").run(); 8 | 9 | assert!( 10 | !result.is_success(), 11 | "{}", 12 | result.failure_message("command to fail") 13 | ); 14 | assert!( 15 | result 16 | .stdout() 17 | .contains("Unable to execute 'git' on your machine"), 18 | "{}", 19 | result.failure_message("to be missing the git command") 20 | ); 21 | } 22 | --------------------------------------------------------------------------------