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