Repository: drewwyatt/git-tidy Branch: main Commit: 270f8aa8f59b Files: 14 Total size: 17.1 KB Directory structure: gitextract_8sk2voo7/ ├── .devcontainer/ │ ├── Dockerfile │ └── devcontainer.json ├── .github/ │ ├── dependabot.yml │ └── workflows/ │ └── ci.yml ├── .gitignore ├── .vscode/ │ └── launch.json ├── Cargo.toml ├── LICENSE ├── README.md └── src/ ├── git/ │ ├── mod.rs │ └── models.rs ├── i18n.rs ├── main.rs └── prompt.rs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .devcontainer/Dockerfile ================================================ FROM rust:1 # This Dockerfile adds a non-root user with sudo access. Update the “remoteUser” property in # devcontainer.json to use it. More info: https://aka.ms/vscode-remote/containers/non-root-user. ARG USERNAME=vscode ARG USER_UID=1000 ARG USER_GID=$USER_UID # Install needed packages and setup non-root user. Use a separate RUN statement to add your own dependencies. RUN apt-get update \ && export DEBIAN_FRONTEND=noninteractive \ && apt-get -y install --no-install-recommends apt-utils dialog 2>&1 \ # # Verify git, needed tools installed && apt-get -y install git openssh-client cmake less iproute2 procps lsb-release \ # # Install lldb, vadimcn.vscode-lldb VSCode extension dependencies && apt-get install -y lldb python3-minimal libpython3.7 \ # # Install Rust components && rustup update 2>&1 \ && rustup component add rls rust-analysis rust-src rustfmt clippy 2>&1 \ # # Create a non-root user to use if preferred - see https://aka.ms/vscode-remote/containers/non-root-user. && groupadd --gid $USER_GID $USERNAME \ && useradd -s /bin/bash --uid $USER_UID --gid $USER_GID -m $USERNAME \ # [Optional] Add sudo support for the non-root user && apt-get install -y sudo \ && echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME\ && chmod 0440 /etc/sudoers.d/$USERNAME \ # # Clean up && apt-get autoremove -y \ && apt-get clean -y \ && rm -rf /var/lib/apt/lists/* # [Optional] Uncomment this section to install additional OS packages. # RUN apt-get update \ # && export DEBIAN_FRONTEND=noninteractive \ # && apt-get -y install --no-install-recommends ================================================ FILE: .devcontainer/devcontainer.json ================================================ { "name": "Rust", "dockerFile": "Dockerfile", "runArgs": [ "--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined" ], // Set *default* container specific settings.json values on container create. "settings": { "terminal.integrated.shell.linux": "/bin/bash", "lldb.executable": "/usr/bin/lldb", // VS Code don't watch files under ./target "files.watcherExclude": { "**/target/**": true } }, // Add the IDs of extensions you want installed when the container is created. "extensions": [ "rust-lang.rust", "bungcip.better-toml", "vadimcn.vscode-lldb", // (Optional) Displays the current CPU stats, memory/disk consumption, clock freq. etc. of the container host in the VS Code status bar. "mutantdino.resourcemonitor" ] // Use 'forwardPorts' to make a list of ports inside the container available locally. // "forwardPorts": [], // Use 'postCreateCommand' to run commands after the container is created. // "postCreateCommand": "rustc --version", // Uncomment to connect as a non-root user. See https://aka.ms/vscode-remote/containers/non-root. // "remoteUser": "vscode" } ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: gomod directory: "/" schedule: interval: daily time: "10:00" open-pull-requests-limit: 10 ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: pull_request: push: branches: ["main"] env: CARGO_TERM_COLOR: always jobs: cargo: runs-on: ubuntu-latest strategy: matrix: command: [fmt, build] include: - command: fmt args: -- --check - command: build args: --verbose steps: - uses: actions/checkout@v2 - name: Setup uses: actions-rs/toolchain@v1 with: toolchain: stable - name: Run ${{ matrix.command }} uses: actions-rs/cargo@v1 with: command: ${{ matrix.command }} args: ${{ matrix.args }} ================================================ FILE: .gitignore ================================================ dist/ gitclean git-tidy *.snap coverage.txt #Added by cargo /target ================================================ FILE: .vscode/launch.json ================================================ { // Use IntelliSense to learn about possible attributes. // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { "type": "lldb", "request": "launch", "name": "Debug executable 'git-tidy'", "cargo": { "args": [ "build", "--bin=git-tidy", "--package=git-tidy" ], "filter": { "name": "git-tidy", "kind": "bin" } }, "args": [], "cwd": "${workspaceFolder}" }, { "type": "lldb", "request": "launch", "name": "Debug unit tests in executable 'git-tidy'", "cargo": { "args": [ "test", "--no-run", "--bin=git-tidy", "--package=git-tidy" ], "filter": { "name": "git-tidy", "kind": "bin" } }, "args": [], "cwd": "${workspaceFolder}" } ] } ================================================ FILE: Cargo.toml ================================================ [package] name = "git-tidy" description = "Tidy up stale git branches." version = "2.0.1" authors = ["Drew Wyatt ", "Dalton Claybrook "] license = "MIT" edition = "2018" repository = "https://github.com/drewwyatt/git-tidy" keywords = ["git", "cli", "command-line", "command-line-tool"] categories = ["command-line-utilities"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] dialoguer = "0.7.1" indicatif = "0.15.0" regex = "1" structopt = { version = "0.3" } ================================================ FILE: LICENSE ================================================ MIT License Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # 🗑 git-tidy [![crates.io](https://img.shields.io/crates/v/git-tidy?style=flat-square)](https://crates.io/crates/git-tidy) Tidy up stale git branches. [![asciicast](https://asciinema.org/a/389715.svg)](https://asciinema.org/a/389715) ## Installation ### Homebrew ```bash $ brew tap drewwyatt/tap $ brew install git-tidy ``` ### Cargo ```bash $ cargo install git-tidy ``` #### ⚠️ You may need to update `cargo` for this ⚠️ If you are seeing an error like the one in [this issue](https://github.com/drewwyatt/git-tidy/issues/45): ``` ▪ cargo install git-tidy Updating crates.io index Installing git-tidy v2.0.1 error: failed to compile `git-tidy v2.0.1`, intermediate artifacts can be found at `/tmp/cargo-installgtcftB` Caused by: failed to select a version for the requirement `zeroize = "^0.9.3"` candidate versions found which didn't match: 1.3.0, 1.2.0, 1.1.1, ... location searched: crates.io index required by package `dialoguer v0.7.1` ... which is depended on by `git-tidy v2.0.1` ``` You can probably fix this by updating cargo with: ```sh rustup update ``` ### Previous versions Newer versions of `git-tidy` are (for now) only available from Homebrew and [crates.io](https://crates.io/crates/git-tidy), but you can still get `1.0.0` from the following places: #### Snapcraft ```bash $ sudo snap install git-tidy ``` #### Go ```bash $ go get -u github.com/drewwyatt/git-tidy ``` ## Usage ```bash $ git tidy # executes "git branch -d" on ": gone" branches ``` ### With force delete ```bash $ git tidy -f # same as above, but with "-D" instead of "-d" # or $ git tidy --force ``` ### Interactive Present all stale (": gone") branches in a checkbox list, allowing user to opt-in to deletions. ```bash $ git tidy -i # or $ git tidy --interactive # with force $ git tidy -if # or $ git tidy --interactive --force ``` ================================================ FILE: src/git/mod.rs ================================================ pub mod models; use regex::Regex; use models::{GitError, GitExec}; pub struct Git where F: Fn(&str) -> (), { gone_branch_regex: Regex, output: Option, _report_progress: F, } impl Git where F: Fn(&str) -> (), { pub fn new(report_progress: F) -> Self { Git { _report_progress: report_progress, output: None, gone_branch_regex: Regex::new( r"(?m)^(?:\*| ) ([^\s]+)\s+[a-z0-9]+ \[[^:\n]+: gone\].*$", ) .unwrap(), } } pub fn delete(&mut self, force: bool, branch_name: &str) -> Result<&mut Self, GitError> { // TODO: figure out how to prevent getting the delete arg in 2 places let delete_arg = if force { "-D" } else { "-d" }; self.report_progress(&format!( "running 'git branch {} {}'", delete_arg, branch_name )); self.output = Some(GitExec::delete(force, branch_name)?); Ok(self) } pub fn fetch(&mut self) -> Result<&mut Self, GitError> { self.report_progress("running 'git fetch'..."); self.output = Some(GitExec::fetch()?); Ok(self) } pub fn prune(&mut self) -> Result<&mut Self, GitError> { self.report_progress("running 'git remote prune origin'..."); self.output = Some(GitExec::prune()?); Ok(self) } pub fn list_branches(&mut self) -> Result<&mut Self, GitError> { self.report_progress("running 'git branch -vv'..."); self.output = Some(GitExec::list_branches()?); Ok(self) } pub fn branch_names(&mut self) -> Result, GitError> { self.output .as_ref() .map(|str| { self.gone_branch_regex .captures_iter(&str) .map(|cap| String::from(&cap[1])) .collect::>() }) .ok_or(GitError::UnknownError) } fn report_progress(&mut self, message: &str) { let rp = &self._report_progress; rp(message); } } ================================================ FILE: src/git/models.rs ================================================ #[derive(Debug)] pub enum GitError { CommandError(String), ExecError(String), ParseError(String), UnknownError, } impl From for GitError { fn from(err: std::io::Error) -> Self { Self::ExecError(err.to_string()) } } impl From for GitError { fn from(err: std::string::FromUtf8Error) -> Self { Self::ParseError(err.to_string()) } } impl From for GitError { fn from(output: std::process::Output) -> Self { String::from_utf8(output.stderr) .map(Self::CommandError) .unwrap_or(Self::UnknownError) } } use std::process::Command; pub struct GitExec {} impl GitExec { pub fn delete(force: bool, branch_name: &str) -> Result { let delete_arg = if force { "-D" } else { "-d" }; let output = Command::new("git") .arg("branch") .arg(delete_arg) .arg(branch_name) .output()?; if output.status.success() { return Ok(String::from_utf8(output.stdout)?); } Err(GitError::from(output)) } pub fn fetch() -> Result { let output = Command::new("git").arg("fetch").output()?; if output.status.success() { return Ok(String::from_utf8(output.stdout)?); } Err(GitError::from(output)) } pub fn prune() -> Result { let output = Command::new("git") .arg("remote") .arg("prune") .arg("origin") .output()?; if output.status.success() { return Ok(String::from_utf8(output.stdout)?); } Err(GitError::from(output)) } pub fn list_branches() -> Result { let output = Command::new("git").arg("branch").arg("-vv").output()?; if output.status.success() { return Ok(String::from_utf8(output.stdout)?); } Err(GitError::from(output)) } } ================================================ FILE: src/i18n.rs ================================================ use std::string::ToString; pub enum Text<'a> { BranchesDeleted, BranchesToDelete, DeletingBranch(&'a str), DeletingBranches, DryRunEnabled, FinishedWithErrors, NoBranchesDeleted, NothingToDo, StartupMessage, UnknownErrorEncountered, } impl<'a> ToString for Text<'a> { fn to_string(&self) -> String { match self { Text::BranchesDeleted => "Branches deleted:".into(), Text::BranchesToDelete => "Branches to delete:".into(), Text::DeletingBranch(name) => format!("Deleting ‘{}’...", name), Text::DeletingBranches => "Deleting branches...".into(), Text::DryRunEnabled => { "\n📣 NOTE: --dry-run enabled, no branches will be deleted.\n".into() } Text::FinishedWithErrors => "Finished with errors:".into(), Text::NoBranchesDeleted => "No branches were deleted.".into(), Text::NothingToDo => "Nothing to do!".into(), Text::StartupMessage => "Tidying up...".into(), Text::UnknownErrorEncountered => "An unknown error was encountered".into(), } } } ================================================ FILE: src/main.rs ================================================ mod git; mod i18n; mod prompt; use indicatif::ProgressBar; use structopt::StructOpt; use git::models::GitError; use git::Git; use i18n::Text; use prompt::Prompt; #[derive(StructOpt)] #[structopt( about = "Tidy up stale git branches.", author = "Drew Wyatt ", name = "git-tidy" )] struct Cli { #[structopt( short, long, help = "Allow deleting branches irrespective of their apparent merged status" )] force: bool, #[structopt( short, long, help = r#"Present all ": gone" branches in list form, allowing opt-in to deletions"# )] interactive: bool, #[structopt(short, long, help = "Print output, but don't delete any branches")] dry_run: bool, } fn main() -> Result<(), GitError> { let args = Cli::from_args(); if args.dry_run { println!("{}", Text::DryRunEnabled.to_string()); } let spinner = ProgressBar::new_spinner(); spinner.set_message(&Text::StartupMessage.to_string()); spinner.enable_steady_tick(160); let mut git = Git::new(|m| spinner.set_message(m)); let gone_branches = git.fetch()?.prune()?.list_branches()?.branch_names()?; if gone_branches.is_empty() { spinner.finish_with_message(&Text::NothingToDo.to_string()); return Ok(()); } spinner.finish_and_clear(); let mut stale_branches = gone_branches; if args.interactive { stale_branches = Prompt::with(stale_branches); if stale_branches.is_empty() { println!("{}", Text::NothingToDo.to_string()); return Ok(()); } } if args.dry_run { println!("{}", Text::BranchesToDelete.to_string()); for branch in stale_branches { println!(" - {}", branch); } println!("") } else { let spinner = ProgressBar::new_spinner(); spinner.set_message(&Text::DeletingBranches.to_string()); spinner.enable_steady_tick(160); let (deleted_branches, deletion_errors) = stale_branches .into_iter() .fold((vec![], vec![]), |(mut del, mut err), branch_name| { spinner.set_message(&Text::DeletingBranch(&branch_name).to_string()); match git.delete(args.force, &branch_name) { Err(GitError::CommandError(msg)) => err.push((branch_name, msg)), Err(GitError::ExecError(msg)) => err.push((branch_name, msg)), Err(GitError::ParseError(msg)) => err.push((branch_name, msg)), Err(GitError::UnknownError) => { err.push((branch_name, Text::UnknownErrorEncountered.to_string())) } _ => del.push(branch_name), }; (del, err) }); spinner.finish_and_clear(); if deleted_branches.is_empty() { println!("{}", Text::NoBranchesDeleted.to_string()); } else { println!("{}", Text::BranchesDeleted.to_string()); for branch_name in deleted_branches { println!(" - {}", branch_name); } } if !deletion_errors.is_empty() { println!("{}", Text::FinishedWithErrors.to_string()); for (branch_name, error) in deletion_errors { println!(" - {}: {}", branch_name, error); } } } Ok(()) } ================================================ FILE: src/prompt.rs ================================================ use dialoguer::{theme::ColorfulTheme, MultiSelect}; pub struct Prompt {} impl Prompt { pub fn with(branches: Vec) -> Vec { let mut branches = branches; let selections = MultiSelect::with_theme(&ColorfulTheme::default()) .with_prompt("Stale branches") .items(&branches) .interact() .unwrap(); selections .into_iter() .map(|idx| branches.swap_remove(idx)) .rev() // sort back to ascending order .collect() } }