├── .editorconfig ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ ├── build.yaml │ └── release.yaml ├── .gitignore ├── .pre-commit-config.actions.yaml ├── .pre-commit-config.yaml ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── build-man.sh ├── build.rs ├── docs ├── git-trim.1 └── git-trim.man ├── images ├── 0-before.png ├── 1-branch-merged.png ├── 2-old-way.png ├── 3-git-trim-in-action.png ├── 4-after.png ├── gvsc-0.png ├── gvsc-1.png ├── gvsc.png └── logo.png ├── screencast.png ├── src ├── args.rs ├── bin │ └── build-man.rs ├── branch.rs ├── config.rs ├── core.rs ├── lib.rs ├── main.rs ├── merge_tracker.rs ├── remote_head_change_checker.rs ├── simple_glob.rs ├── subprocess.rs └── util.rs └── tests ├── config.rs ├── filter_accidential_track.rs ├── fixture └── mod.rs ├── hub_cli_checkout.rs ├── merge_styles.rs ├── non_trackings_non_upstreams.rs ├── orphan.rs ├── simple_git_flow.rs ├── simple_github_flow.rs ├── triangular_git_flow.rs ├── triangular_github_flow.rs └── worktree.rs /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | end_of_line = lf 9 | charset = utf-8 10 | 11 | [*.{yaml,yml,toml}] 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text 2 | *.png binary 3 | 4 | Cargo.lock linguist-generated=true 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Check your version before submitting the bug** 11 | `git-trim` is still `0.x` version and I do make a lot of silly bugs. 12 | Some bugs might be fixed on upstream version. Please update it and make sure that you're using the upstream version 13 | especially you've installed `git-trim` other than `cargo install` such as Homebrew or AUR. 14 | 15 | **Describe the bug** 16 | A clear and concise description of what the bug is. 17 | 18 | **To Reproduce** 19 | 1. Minimal reproducible git repo if available 20 | 2. CLI command and configs 21 | 3. Steps to reproduce the behavior 22 | 23 | **Expected behavior** 24 | A clear and concise description of what you expected to happen. 25 | 26 | **Actual behaviour** 27 | If applicable, add logs and stacktraces to help explain your problem. 28 | 29 | **Additional context and logs & dumps if necessary** 30 | You should remove sensitive informations before put them here. 31 | - OS 32 | - Version 33 | - `git rev-parse --abbrev-ref HEAD` 34 | - `git show-ref` 35 | - `git config --get-regexp '(push|fetch|remote|branch|trim).*' | sort` 36 | - `git log --oneline --graph --all` 37 | 38 | **Logs and stacktraces if necessary** 39 | You should remove sensitive informations before put them here. 40 | You can get more detailed and clean logs by setting some environment variable with follwing command 41 | ```shell 42 | export RUST_LOG=trace 43 | export RAYON_NUM_THREADS=1 44 | export RUST_BACKTRACE=full 45 | git trim 46 | ``` 47 | 48 | ``` 49 | Put them here 50 | ``` 51 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | pre-commit: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - run: | 17 | rustup set profile default 18 | rustup show 19 | - uses: actions/setup-python@v5 20 | with: 21 | python-version: 3.11 22 | - name: Install pre-commit 23 | run: | 24 | pip install pre-commit 25 | pre-commit install --config .pre-commit-config.actions.yaml 26 | - name: pre-commit 27 | run: | 28 | pre-commit run --config .pre-commit-config.actions.yaml --all-files --show-diff-on-failure 29 | 30 | clippy: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v4 34 | - run: | 35 | rustup set profile default 36 | rustup show 37 | - run: cargo fetch --verbose 38 | - uses: actions-rs/clippy-check@v1 39 | with: 40 | token: ${{ secrets.GITHUB_TOKEN }} 41 | args: --all-features -- -D warnings 42 | 43 | test: 44 | runs-on: ${{ matrix.os.long }} 45 | strategy: 46 | matrix: 47 | os: 48 | - long: ubuntu-latest 49 | short: linux 50 | - long: macOS-latest 51 | short: mac 52 | - long: windows-latest 53 | short: win 54 | fail-fast: false 55 | steps: 56 | - uses: actions/checkout@v4 57 | - run: | 58 | rustup set profile minimal 59 | rustup show 60 | - run: cargo fetch --verbose 61 | - run: cargo build --tests 62 | - run: cargo test --all 63 | shell: bash 64 | env: 65 | RUST_LOG: trace 66 | RUST_BACKTRACE: 1 67 | 68 | build: 69 | runs-on: ${{ matrix.os.long }} 70 | strategy: 71 | matrix: 72 | os: 73 | - long: ubuntu-latest 74 | short: linux 75 | - long: macOS-latest 76 | short: mac 77 | - long: windows-latest 78 | short: win 79 | fail-fast: false 80 | steps: 81 | - uses: actions/checkout@v4 82 | - run: | 83 | rustup set profile minimal 84 | rustup show 85 | - run: cargo fetch --verbose 86 | - run: cargo check 87 | env: 88 | RUSTFLAGS: -D warnings 89 | - run: cargo build 90 | - name: Archive 91 | shell: bash 92 | working-directory: target/debug 93 | run: | 94 | VERSION=$(./git-trim --version | cut -d ' ' -f 2) 95 | echo "VERSION=$VERSION" >> $GITHUB_ENV 96 | 97 | rm -rf artifacts 98 | mkdir -p artifacts 99 | cp 'git-trim' artifacts/ 100 | echo '${{github.sha}} ${{github.ref}}' | tee artifacts/git-ref 101 | if command -v sha256sum; then 102 | sha256sum 'git-trim' | tee artifacts/sha256sums 103 | else 104 | shasum -a 256 'git-trim' | tee artifacts/sha256sums 105 | fi 106 | 107 | - uses: actions/upload-artifact@v4 108 | with: 109 | name: git-trim-${{matrix.os.short}}-${{env.VERSION}} 110 | path: target/debug/artifacts/ 111 | 112 | docs-are-up-to-date: 113 | runs-on: ubuntu-latest 114 | steps: 115 | - uses: actions/checkout@v4 116 | - run: | 117 | rustup set profile minimal 118 | rustup show 119 | - run: cargo fetch --verbose 120 | - run: ./build-man.sh build 121 | - run: ./build-man.sh run 122 | - name: Check docs are up-to-date 123 | run: git diff --exit-code HEAD -- docs 124 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | upload-artifacts: 10 | runs-on: ${{ matrix.os.long }} 11 | strategy: 12 | matrix: 13 | os: 14 | - long: ubuntu-latest 15 | short: linux 16 | - long: macOS-latest 17 | short: mac 18 | - long: windows-latest 19 | short: win 20 | steps: 21 | - uses: actions/checkout@v4 22 | - run: | 23 | rustup set profile minimal 24 | rustup show 25 | - run: cargo fetch --verbose 26 | - run: cargo build --release 27 | - name: Archive 28 | shell: bash 29 | working-directory: target/release 30 | run: | 31 | VERSION="${{github.ref}}" 32 | VERSION="${VERSION#refs/tags/}" 33 | ARCHIVE="git-trim-${{matrix.os.short}}-$VERSION.tgz" 34 | echo "VERSION=$VERSION" >> $GITHUB_ENV 35 | echo "ARCHIVE=$ARCHIVE" >> $GITHUB_ENV 36 | 37 | rm -rf artifacts 38 | mkdir -p artifacts/git-trim 39 | cp 'git-trim' artifacts/git-trim/ 40 | echo '${{github.sha}} ${{github.ref}}' | tee artifacts/git-trim/git-ref 41 | 42 | if command -v sha256sum; then 43 | sha256sum 'git-trim' | tee artifacts/git-trim/sha256sums 44 | else 45 | shasum -a 256 'git-trim' | tee artifacts/git-trim/sha256sums 46 | fi 47 | 48 | cd artifacts 49 | tar cvzf "$ARCHIVE" git-trim 50 | - uses: actions/upload-artifact@v1 51 | with: 52 | name: git-trim-${{matrix.os.short}}-${{env.VERSION}} 53 | path: target/release/artifacts/${{env.ARCHIVE}} 54 | 55 | github-release: 56 | needs: 57 | - upload-artifacts 58 | runs-on: ubuntu-latest 59 | env: 60 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 61 | steps: 62 | - run: | 63 | VERSION="${{github.ref}}" 64 | VERSION="${VERSION#refs/tags/}" 65 | echo "VERSION=$VERSION" >> $GITHUB_ENV 66 | - uses: actions/download-artifact@v4 67 | with: 68 | name: git-trim-linux-${{env.VERSION}} 69 | path: ./ 70 | - uses: actions/download-artifact@v4 71 | with: 72 | name: git-trim-mac-${{env.VERSION}} 73 | path: ./ 74 | - uses: actions/download-artifact@v4 75 | with: 76 | name: git-trim-win-${{env.VERSION}} 77 | path: ./ 78 | 79 | - name: Create Release 80 | id: create_release 81 | uses: actions/create-release@v1 82 | env: 83 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 84 | with: 85 | tag_name: ${{ github.ref }} 86 | release_name: Release ${{ github.ref }} 87 | draft: true 88 | prerelease: true 89 | 90 | - name: Upload Release Asset - linux 91 | uses: actions/upload-release-asset@v1 92 | with: 93 | upload_url: ${{ steps.create_release.outputs.upload_url }} # This pulls from the CREATE RELEASE step above, referencing it's ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps 94 | asset_path: ./git-trim-linux-${{env.VERSION}}.tgz 95 | asset_name: git-trim-linux-${{env.VERSION}}.tgz 96 | asset_content_type: application/gzip 97 | 98 | - name: Upload Release Asset - mac 99 | uses: actions/upload-release-asset@v1 100 | with: 101 | upload_url: ${{ steps.create_release.outputs.upload_url }} 102 | asset_path: ./git-trim-mac-${{env.VERSION}}.tgz 103 | asset_name: git-trim-mac-${{env.VERSION}}.tgz 104 | asset_content_type: application/gzip 105 | 106 | - name: Upload Release Asset - win 107 | uses: actions/upload-release-asset@v1 108 | with: 109 | upload_url: ${{ steps.create_release.outputs.upload_url }} 110 | asset_path: ./git-trim-win-${{env.VERSION}}.tgz 111 | asset_name: git-trim-win-${{env.VERSION}}.tgz 112 | asset_content_type: application/gzip 113 | 114 | cargo-publish: 115 | runs-on: ubuntu-latest 116 | steps: 117 | - uses: actions/checkout@v4 118 | - run: | 119 | rustup set profile minimal 120 | rustup show 121 | - run: cargo fetch --verbose 122 | - name: Cargo publish 123 | run: | 124 | cargo publish --token ${{ secrets.CRATES_IO_TOKEN }} 125 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/clion,rust 3 | # Edit at https://www.gitignore.io/?templates=clion,rust 4 | 5 | ### CLion ### 6 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 7 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 8 | 9 | # User-specific stuff 10 | .idea/**/workspace.xml 11 | .idea/**/tasks.xml 12 | .idea/**/usage.statistics.xml 13 | .idea/**/dictionaries 14 | .idea/**/shelf 15 | 16 | # Generated files 17 | .idea/**/contentModel.xml 18 | 19 | # Sensitive or high-churn files 20 | .idea/**/dataSources/ 21 | .idea/**/dataSources.ids 22 | .idea/**/dataSources.local.xml 23 | .idea/**/sqlDataSources.xml 24 | .idea/**/dynamic.xml 25 | .idea/**/uiDesigner.xml 26 | .idea/**/dbnavigator.xml 27 | 28 | # Gradle 29 | .idea/**/gradle.xml 30 | .idea/**/libraries 31 | 32 | # Gradle and Maven with auto-import 33 | # When using Gradle or Maven with auto-import, you should exclude module files, 34 | # since they will be recreated, and may cause churn. Uncomment if using 35 | # auto-import. 36 | # .idea/modules.xml 37 | # .idea/*.iml 38 | # .idea/modules 39 | # *.iml 40 | # *.ipr 41 | 42 | # CMake 43 | cmake-build-*/ 44 | 45 | # Mongo Explorer plugin 46 | .idea/**/mongoSettings.xml 47 | 48 | # File-based project format 49 | *.iws 50 | 51 | # IntelliJ 52 | out/ 53 | 54 | # mpeltonen/sbt-idea plugin 55 | .idea_modules/ 56 | 57 | # JIRA plugin 58 | atlassian-ide-plugin.xml 59 | 60 | # Cursive Clojure plugin 61 | .idea/replstate.xml 62 | 63 | # Crashlytics plugin (for Android Studio and IntelliJ) 64 | com_crashlytics_export_strings.xml 65 | crashlytics.properties 66 | crashlytics-build.properties 67 | fabric.properties 68 | 69 | # Editor-based Rest Client 70 | .idea/httpRequests 71 | 72 | # Android studio 3.1+ serialized cache file 73 | .idea/caches/build_file_checksums.ser 74 | 75 | ### CLion Patch ### 76 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 77 | 78 | # *.iml 79 | # modules.xml 80 | # .idea/misc.xml 81 | # *.ipr 82 | 83 | # Sonarlint plugin 84 | .idea/**/sonarlint/ 85 | 86 | # SonarQube Plugin 87 | .idea/**/sonarIssues.xml 88 | 89 | # Markdown Navigator plugin 90 | .idea/**/markdown-navigator.xml 91 | .idea/**/markdown-navigator/ 92 | 93 | ### Rust ### 94 | # Generated by Cargo 95 | # will have compiled files and executables 96 | /target/ 97 | 98 | # These are backup files generated by rustfmt 99 | **/*.rs.bk 100 | 101 | # End of https://www.gitignore.io/api/clion,rust 102 | .idea 103 | -------------------------------------------------------------------------------- /.pre-commit-config.actions.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v2.4.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | exclude: ^docs/.* # exclude generated docs 8 | - id: check-yaml 9 | - id: check-toml 10 | - repo: local 11 | hooks: 12 | - id: fmt 13 | name: fmt 14 | description: Format files with rustfmt. 15 | entry: cargo fmt -- 16 | language: system 17 | types: [rust] 18 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v2.4.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | exclude: ^docs/.* # exclude generated docs 8 | - id: check-yaml 9 | - id: check-toml 10 | - repo: local 11 | hooks: 12 | - id: fmt 13 | name: fmt 14 | description: Format files with rustfmt. 15 | entry: cargo fmt -- 16 | language: system 17 | types: [rust] 18 | - id: clippy 19 | name: clippy 20 | description: Lint rust sources 21 | entry: cargo clippy --all-targets --all-features -- -D warnings 22 | language: system 23 | types: [rust] 24 | pass_filenames: false 25 | - id: check 26 | name: check 27 | description: Check compilation errors 28 | entry: cargo check --all-targets --all-features 29 | language: system 30 | types: [rust] 31 | pass_filenames: false 32 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "git-trim" 3 | description = "Automatically trims your tracking branches whose upstream branches are merged or stray" 4 | license = "MIT" 5 | version = "0.4.4" 6 | authors = ["SeongChan Lee "] 7 | repository = "https://github.com/foriequal0/git-trim" 8 | readme = "README.md" 9 | keywords = ["git", "branch", "prune", "trim"] 10 | categories = ["command-line-utilities", "development-tools"] 11 | edition = "2021" 12 | rust-version = "1.65" 13 | build = "build.rs" 14 | default-run = "git-trim" 15 | 16 | [[bin]] 17 | name = "build-man" 18 | required-features = ["build-man"] 19 | 20 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 21 | 22 | [features] 23 | build-man = ["man"] 24 | 25 | [build-dependencies] 26 | anyhow = "1.0.95" 27 | vergen-gix = { version = "1.0.0", features = ["build", "cargo", "rustc", "si"] } 28 | 29 | [dependencies] 30 | anyhow = "1.0.95" 31 | clap = { version = "4.5.23", features = ["derive"] } 32 | crossbeam-channel = "0.5.14" 33 | dialoguer = "0.11.0" 34 | env_logger = "0.11.6" 35 | git2 = "0.19.0" 36 | log = "0.4.22" 37 | man = { version = "0.3.0", optional = true } 38 | rayon = "1.10.0" 39 | textwrap = { version = "0.16.1", features = ["terminal_size"] } 40 | thiserror = "2.0.9" 41 | 42 | [dev-dependencies] 43 | tempfile = "3.3.0" 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 SeongChan Lee 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CI](https://github.com/foriequal0/git-trim/workflows/CI/badge.svg?event=push)](https://github.com/foriequal0/git-trim/actions?query=workflow%3ACI) [![crates.io](https://img.shields.io/crates/v/git-trim.svg)](https://crates.io/crates/git-trim) 2 | 3 | git-trim 4 | ======== 5 | 6 | ![git-trim Logo](images/logo.png) 7 | 8 | `git-trim` automatically trims your tracking branches whose upstream branches are merged or stray. 9 | 10 | `git-trim` is a missing companion to the `git fetch --prune` and a proper, safer, faster alternative to your `` 11 | 12 | [Instruction](#instruction) | [Configurations](#configurations) | [FAQ](#faq) 13 | 14 | ## Instruction 15 | 16 | ### Screencast 17 | 18 | ![git-trim screencast](screencast.png) 19 | 20 | ### Installation 21 | Download binary from [Releases](https://github.com/foriequal0/git-trim/releases), and put it under your `PATH` directories. 22 | 23 | You can also install with `cargo install git-trim` if you have `cargo`. 24 | 25 | It uses [`git2`](https://crates.io/crates/git2) under the hood which depends conditionally on [`openssl-sys`](https://crates.io/crates/openssl) on *nix platform. 26 | You might need to install `libssl-dev` and `pkg-config` packages if you build from the source. See: https://docs.rs/openssl/0.10.28/openssl/#automatic 27 | 28 | ### How to use 29 | 1. Don't forget to set an upstream for a branch that you want to trim automatically. 30 | `git push -u ` will set an upstream for you on push. 31 | 1. Run `git trim` if you need to trim branches especially after PR reviews. It'll automatically recognize merged or stray branches, and delete it. 32 | 1. You can also `git trim --dry-run` when you don't trust me. 33 | 34 | #### Are you using git-flow? 35 | 36 | Don't forget to `git config trim.bases develop,master`. 37 | 38 | ## Why have you made this? Show me how it works. 39 | 40 | ### `git fetch --prune` doesn't do all the works for you 41 | 42 | There are so many lines of commands to type and many statuses of branches that corresponding to PRs that you've sent. 43 | Were they merged or rejected? Did I forget to delete the remote branch after it is merged? 44 | 45 | After some working with the repository, you'll execute `git fetch --prune` or `git remote update --prune` occasionally. 46 | However, you'll likely see the mess of local branches whose upstreams are already merged and deleted on the remote. 47 | Because `git fetch --prune` only deletes remote-tracking branches (or remote references, `refs/remotes//`) but not local tracking branches (`refs/heads/`) for you. 48 | It is worse if remote branches that are merged but the maintainer forgot to delete them, 49 | the remote-tracking branches would not be deleted and so on even if you know that it is merged into the master. 50 | 51 | ![before](images/0-before.png) 52 | 53 | They are tedious to delete manually. `git branch --merged`'ll likely to betray you when branches are rebase merged or squash merged. 54 | 55 | ![git branch --merged doesn't help](images/1-branch-merged.png) 56 | 57 | After the PR is merged or rejected, you're likely to delete them manually if you don't have `git-trim` but it is tedious to type and error-prone. 58 | 59 | ![old way of deleting them](images/2-old-way.png) 60 | 61 | You repeat these same commands as much as PRs that you've sent. 62 | You have to remember what local branch is for the PR that just have been closed and it is easy to make a mistake. 63 | I feel nervous whenever I put `--force` flag. Rebase merge forces to me to use `--force` (no pun is intended). 64 | `git reflog` is a fun command to play with, isn't it? Also `git remote update` and `git push` is not instantaneous. 65 | I hate to wait for the prompt even it is a fraction of a second when I have multiple commands to type. 66 | 67 | ![gvsc before](images/gvsc-0.png) 68 | 69 | ### Why don't you just use `git fetch --prune` or `git | xargs git branch -D` 70 | 71 | See [FAQ](#faq) 72 | 73 | ### See how `git-trim` works! 74 | 75 | It is enough to type just `git trim` and hit the `y` key once. 76 | 77 | ![git trim](images/3-git-trim-in-action.png) 78 | 79 | Voila! 80 | 81 | ![after](images/4-after.png) 82 | 83 | That's why I've made `git-trim`. 84 | It knows whether a branch is merged into the base branches, or whether it is rejected. 85 | It can even `push --delete` when you forgot to delete the remote branch if needed. 86 | 87 | ![gvsc after](images/gvsc-1.png) 88 | 89 | ## Configurations 90 | 91 | See `--help` or [docs](docs/git-trim.man) 92 | 93 | ## FAQ 94 | 95 | ### What is different to `git fetch --prune`? 96 | 97 | git fetch --prune only deletes remote-tracking branches (or remote references, `refs/remotes/...`) when the remote branches are deleted. 98 | 99 | The problem is that it doesn't touch local tracking branches that track the remote upstream branches 100 | even if the upstreams are merged into the base and deleted by somehow. You should manually delete corresponding tracking branches in that case. 101 | If you use rebase merge, you might have to use scary `--force` flag such as `git branch --delete --force`. 102 | 103 | `git-trim` does detect whether the upstream branches are merged into the upstream of the base branch. 104 | It knows whether it is safe to delete, and even knows that you forgot to delete the remote branch after the merge. 105 | 106 | ### What is different to ` | xargs git branch -D` 107 | 108 | Just deleting tracking branches whose upstreams are gone with `-D`, which implies `--force`, 109 | needs an extra caution since it might delete contents that are not fully merged into the base or modified after being merged. 110 | Not because `--force` is dangerous. Just `gone` doesn't mean it is fully merged to the base. So I gave it steroids, and it became `git-trim`. 111 | 112 | * It inspects the upstream of tracking branches whether they are 'fully' merged, not just whether they are gone. 113 | I've spent about half of the code on scenario tests. I wanted to make sure that it doesn't delete unmerged contents accidentally in any case. 114 | * It supports github flow (master-feature tiered branch strategy), git flow (master-develop-feature tiered branch strategy), 115 | and simple workflow (with a remote repo and a local clone), and triangular workflow (with two remote repos and a local clone). 116 | * It is merge styles agnostic. It can detect common merge styles such as merge with a merge commit, rebase/ff merge and squash merge. 117 | * It can also inspect remote branches so it deletes them from remotes for you in case you've forgotten to. 118 | * Moreover, it runs in parallel. Otherwise, large repos with hundreds of stale branches would've taken a couple of minutes to inspect whether they are merged. 119 | 120 | ### What kind of merge styles that `git-trim` support? 121 | 122 | * A classic merge with a merge commit with `git merge --no-ff` 123 | * A rebase merge with `git merge --ff-only` (With `git cherry` equivalents) 124 | * A squash merge with `git merge --squash` (With this method: https://stackoverflow.com/a/56026209) 125 | 126 | ### What is the difference between the `merged` and `stray` branch? 127 | 128 | A merged branch is a branch whose upstream branch is fully merged onto the upstream of the base branch so you're not going to lose the changes. 129 | 130 | In contrast, a stray branch is a branch that there is a chance to lose some changes if you delete it. 131 | Your PRs are sometimes rejected and deleted from the remote. 132 | Or you might have been mistakenly amended or rebased the branch and the patch is now completely different from the patch 133 | that is merged because you forgot the fact that the PR is already merged. 134 | Then they are not safe to delete blindly just because their upstreams are deleted. 135 | The term is borrowed from the git's remote tracking states. 136 | 137 | ### I'm even more lazy to type `git trim` 138 | 139 | Try this `post-merge` hook. It automatically calls `git trim --no-update` everytime you `git pull` on `master` or `develop`. `git config fetch.prune true` is recommended with this hook. 140 | ```shell 141 | #!/bin/bash 142 | BRANCH=$(git rev-parse --abbrev-ref HEAD) 143 | case "$HEAD_BRANCH" in 144 | "master"|"develop") ;; 145 | *) exit ;; 146 | esac 147 | 148 | git trim --no-update 149 | ``` 150 | Or try [`git-sync`](https://gist.github.com/foriequal0/55763d9177803c325904d089299f0970) script. It pulls & prunes & trims in a single command. 151 | 152 | ### `trim`? `stray`? They are weird choices of terms. 153 | 154 | I wanted to use `prune`, `stale`, but they are already taken. 155 | 156 | ## Disclaimers 157 | Git and the Git logo are either registered trademarks or trademarks of Software Freedom Conservancy, Inc., corporate home of the Git Project, in the United States and/or other countries. 158 | 159 | The logo is a derivative work of [Git Logo](https://git-scm.com/downloads/logos). Git Logo by [Jason Long](https://twitter.com/jasonlong) is licensed under the [Creative Commons Attribution 3.0 Unported License](https://creativecommons.org/licenses/by/3.0/). The logo uses Bitstream Charter. 160 | 161 | Images of a man with heartburn are generated with [https://gvsc.rajephon.dev](https://gvsc.rajephon.dev) 162 | -------------------------------------------------------------------------------- /build-man.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | MODE=$1 4 | case $MODE in 5 | build) 6 | cargo build --bin build-man --features build-man 7 | ;; 8 | run|"") 9 | mkdir -p docs/ 10 | cargo run --bin build-man --features build-man > docs/git-trim.1 11 | MANWIDTH=120 man --no-hyphenation --no-justification docs/git-trim.1 > docs/git-trim.man 12 | ;; 13 | *) 14 | echo "Unknown mode: $MODE" 15 | exit -1 16 | ;; 17 | esac 18 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | use vergen_gix::{BuildBuilder, CargoBuilder, Emitter, GixBuilder, RustcBuilder, SysinfoBuilder}; 2 | 3 | fn main() -> anyhow::Result<()> { 4 | Emitter::default() 5 | .add_instructions(&BuildBuilder::all_build()?)? 6 | .add_instructions(&CargoBuilder::all_cargo()?)? 7 | .add_instructions(&GixBuilder::all_git()?)? 8 | .add_instructions(&RustcBuilder::all_rustc()?)? 9 | .add_instructions(&SysinfoBuilder::all_sysinfo()?)? 10 | .emit() 11 | } 12 | -------------------------------------------------------------------------------- /docs/git-trim.1: -------------------------------------------------------------------------------- 1 | .TH GIT-TRIM 1 2 | .SH NAME 3 | git\-trim \- Automatically trims your tracking branches whose upstream branches are merged or stray. 4 | .SH SYNOPSIS 5 | \fBgit\-trim\fR [FLAGS] [OPTIONS] 6 | .SH FLAGS 7 | .TP 8 | \fB\-h\fR, \fB\-\-help\fR 9 | Prints help information 10 | 11 | .TP 12 | \fB\-\-no\-update\fR 13 | Do not update remotes [config: trim.update] 14 | 15 | .TP 16 | \fB\-\-no\-confirm\fR 17 | Do not ask confirm [config: trim.confirm] 18 | 19 | .TP 20 | \fB\-\-no\-detach\fR 21 | Do not detach when HEAD is about to be deleted [config: trim.detach] 22 | 23 | .TP 24 | \fB\-\-dry\-run\fR 25 | Do not delete branches, show what branches will be deleted 26 | .SH OPTIONS 27 | .TP 28 | \fB\-b\fR, \fB\-\-bases\fR=\fIbases\fR 29 | Comma separated multiple names of branches. All the other branches are compared with the upstream branches of those branches. [default: branches that tracks `git symbolic\-ref refs/remotes/*/HEAD`] [config: trim.bases] 30 | 31 | The default value is a branch that tracks `git symbolic\-ref refs/remotes/*/HEAD`. They might not be reflected correctly when the HEAD branch of your remote repository is changed. You can see the changed HEAD branch name with `git remote show ` and apply it to your local repository with `git remote set\-head \-\-auto`. 32 | 33 | .TP 34 | \fB\-p\fR, \fB\-\-protected\fR=\fIprotected\fR 35 | Comma separated multiple glob patterns (e.g. `release\-*`, `feature/*`) of branches that should never be deleted. [config: trim.protected] 36 | 37 | .TP 38 | \fB\-\-update\-interval\fR=\fIupdate_interval\fR 39 | Prevents too frequent updates. Seconds between updates in seconds. 0 to disable. [default: 5] [config: trim.updateInterval] 40 | 41 | .TP 42 | \fB\-d\fR, \fB\-\-delete\fR=\fIdelete\fR 43 | Comma separated values of `[:]`. Delete range is one of the `merged, merged\-local, merged\-remote, stray, diverged, local, remote`. `:` is only necessary to a `` when the range is applied to remote branches. You can use `*` as `` to delete a range of branches from all remotes. [default : `merged:origin`] [config: trim.delete] 44 | 45 | `merged` implies `merged\-local,merged\-remote`. 46 | 47 | `merged\-local` will delete merged tracking local branches. `merged\-remote:` will delete merged upstream branches from ``. `stray` will delete tracking local branches, which is not merged, but the upstream is gone. `diverged:` will delete merged tracking local branches, and their upstreams from `` even if the upstreams are not merged and diverged from local ones. `local` will delete non\-tracking merged local branches. `remote:` will delete non\-upstream merged remote tracking branches. Use with caution when you are using other than `merged`. It might lose changes, and even nuke repositories. 48 | .SH EXIT STATUS 49 | .TP 50 | \fB0\fR 51 | Successful program execution. 52 | 53 | .TP 54 | \fB1\fR 55 | Unsuccessful program execution. 56 | 57 | .TP 58 | \fB101\fR 59 | The program panicked. 60 | 61 | -------------------------------------------------------------------------------- /docs/git-trim.man: -------------------------------------------------------------------------------- 1 | GIT-TRIM(1) General Commands Manual GIT-TRIM(1) 2 | 3 | NAME 4 | git-trim - Automatically trims your tracking branches whose upstream branches are merged or stray. 5 | 6 | SYNOPSIS 7 | git-trim [FLAGS] [OPTIONS] 8 | 9 | FLAGS 10 | -h, --help 11 | Prints help information 12 | 13 | --no-update 14 | Do not update remotes [config: trim.update] 15 | 16 | --no-confirm 17 | Do not ask confirm [config: trim.confirm] 18 | 19 | --no-detach 20 | Do not detach when HEAD is about to be deleted [config: trim.detach] 21 | 22 | --dry-run 23 | Do not delete branches, show what branches will be deleted 24 | 25 | OPTIONS 26 | -b, --bases=bases 27 | Comma separated multiple names of branches. All the other branches are compared with the upstream 28 | branches of those branches. [default: branches that tracks `git symbolic-ref refs/remotes/*/HEAD`] 29 | [config: trim.bases] 30 | 31 | The default value is a branch that tracks `git symbolic-ref refs/remotes/*/HEAD`. They might not be 32 | reflected correctly when the HEAD branch of your remote repository is changed. You can see the changed 33 | HEAD branch name with `git remote show ` and apply it to your local repository with `git remote 34 | set-head --auto`. 35 | 36 | -p, --protected=protected 37 | Comma separated multiple glob patterns (e.g. `release-*`, `feature/*`) of branches that should never be 38 | deleted. [config: trim.protected] 39 | 40 | --update-interval=update_interval 41 | Prevents too frequent updates. Seconds between updates in seconds. 0 to disable. [default: 5] [config: 42 | trim.updateInterval] 43 | 44 | -d, --delete=delete 45 | Comma separated values of `[:]`. Delete range is one of the `merged, 46 | merged-local, merged-remote, stray, diverged, local, remote`. `:` is only necessary to a 47 | `` when the range is applied to remote branches. You can use `*` as `` to 48 | delete a range of branches from all remotes. [default : `merged:origin`] [config: trim.delete] 49 | 50 | `merged` implies `merged-local,merged-remote`. 51 | 52 | `merged-local` will delete merged tracking local branches. `merged-remote:` will delete merged 53 | upstream branches from ``. `stray` will delete tracking local branches, which is not merged, 54 | but the upstream is gone. `diverged:` will delete merged tracking local branches, and their 55 | upstreams from `` even if the upstreams are not merged and diverged from local ones. `local` 56 | will delete non-tracking merged local branches. `remote:` will delete non-upstream merged 57 | remote tracking branches. Use with caution when you are using other than `merged`. It might lose 58 | changes, and even nuke repositories. 59 | 60 | EXIT STATUS 61 | 0 Successful program execution. 62 | 63 | 1 Unsuccessful program execution. 64 | 65 | 101 The program panicked. 66 | 67 | GIT-TRIM(1) 68 | -------------------------------------------------------------------------------- /images/0-before.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foriequal0/git-trim/07c2f508308a4a59dfb333969518d02f8e328983/images/0-before.png -------------------------------------------------------------------------------- /images/1-branch-merged.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foriequal0/git-trim/07c2f508308a4a59dfb333969518d02f8e328983/images/1-branch-merged.png -------------------------------------------------------------------------------- /images/2-old-way.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foriequal0/git-trim/07c2f508308a4a59dfb333969518d02f8e328983/images/2-old-way.png -------------------------------------------------------------------------------- /images/3-git-trim-in-action.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foriequal0/git-trim/07c2f508308a4a59dfb333969518d02f8e328983/images/3-git-trim-in-action.png -------------------------------------------------------------------------------- /images/4-after.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foriequal0/git-trim/07c2f508308a4a59dfb333969518d02f8e328983/images/4-after.png -------------------------------------------------------------------------------- /images/gvsc-0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foriequal0/git-trim/07c2f508308a4a59dfb333969518d02f8e328983/images/gvsc-0.png -------------------------------------------------------------------------------- /images/gvsc-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foriequal0/git-trim/07c2f508308a4a59dfb333969518d02f8e328983/images/gvsc-1.png -------------------------------------------------------------------------------- /images/gvsc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foriequal0/git-trim/07c2f508308a4a59dfb333969518d02f8e328983/images/gvsc.png -------------------------------------------------------------------------------- /images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foriequal0/git-trim/07c2f508308a4a59dfb333969518d02f8e328983/images/logo.png -------------------------------------------------------------------------------- /screencast.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foriequal0/git-trim/07c2f508308a4a59dfb333969518d02f8e328983/screencast.png -------------------------------------------------------------------------------- /src/args.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | use std::fmt::Debug; 3 | use std::hash::Hash; 4 | use std::iter::FromIterator; 5 | use std::mem::discriminant; 6 | use std::process::exit; 7 | use std::str::FromStr; 8 | 9 | use clap::Parser; 10 | use thiserror::Error; 11 | 12 | #[derive(Parser, Default)] 13 | #[clap( 14 | version, 15 | about = "Automatically trims your tracking branches whose upstream branches are merged or stray.", 16 | long_about = "Automatically trims your tracking branches whose upstream branches are merged or stray. 17 | `git-trim` is a missing companion to the `git fetch --prune` and a proper, safer, faster alternative to your ``." 18 | )] 19 | pub struct Args { 20 | /// Comma separated multiple names of branches. 21 | /// All the other branches are compared with the upstream branches of those branches. 22 | /// [default: branches that tracks `git symbolic-ref refs/remotes/*/HEAD`] [config: trim.bases] 23 | /// 24 | /// The default value is a branch that tracks `git symbolic-ref refs/remotes/*/HEAD`. 25 | /// They might not be reflected correctly when the HEAD branch of your remote repository is changed. 26 | /// You can see the changed HEAD branch name with `git remote show ` 27 | /// and apply it to your local repository with `git remote set-head --auto`. 28 | #[clap(short, long, value_delimiter = ',', aliases=&["base"])] 29 | pub bases: Vec, 30 | 31 | /// Comma separated multiple glob patterns (e.g. `release-*`, `feature/*`) of branches that should never be deleted. 32 | /// [config: trim.protected] 33 | #[clap(short, long, value_delimiter = ',')] 34 | pub protected: Vec, 35 | 36 | /// Do not update remotes 37 | /// [config: trim.update] 38 | #[clap(long)] 39 | pub no_update: bool, 40 | #[clap(long, hide(true))] 41 | pub update: bool, 42 | 43 | /// Prevents too frequent updates. Seconds between updates in seconds. 0 to disable. 44 | /// [default: 5] [config: trim.updateInterval] 45 | #[clap(long)] 46 | pub update_interval: Option, 47 | 48 | /// Do not ask confirm 49 | /// [config: trim.confirm] 50 | #[clap(long)] 51 | pub no_confirm: bool, 52 | #[clap(long, hide(true))] 53 | pub confirm: bool, 54 | 55 | /// Do not detach when HEAD is about to be deleted 56 | /// [config: trim.detach] 57 | #[clap(long)] 58 | pub no_detach: bool, 59 | #[clap(long, hide(true))] 60 | pub detach: bool, 61 | 62 | /// Comma separated values of `[:]`. 63 | /// Delete range is one of the `merged, merged-local, merged-remote, stray, diverged, local, remote`. 64 | /// `:` is only necessary to a `` when the range is applied to remote branches. 65 | /// You can use `*` as `` to delete a range of branches from all remotes. 66 | /// [default : `merged:origin`] [config: trim.delete] 67 | /// 68 | /// `merged` implies `merged-local,merged-remote`. 69 | /// 70 | /// `merged-local` will delete merged tracking local branches. 71 | /// `merged-remote:` will delete merged upstream branches from ``. 72 | /// `stray` will delete tracking local branches, which is not merged, but the upstream is gone. 73 | /// `diverged:` will delete merged tracking local branches, and their upstreams from `` even if the upstreams are not merged and diverged from local ones. 74 | /// `local` will delete non-tracking merged local branches. 75 | /// `remote:` will delete non-upstream merged remote tracking branches. 76 | /// Use with caution when you are using other than `merged`. It might lose changes, and even nuke repositories. 77 | #[clap(short, long, value_delimiter = ',')] 78 | pub delete: Vec, 79 | 80 | /// Do not delete branches, show what branches will be deleted. 81 | #[clap(long)] 82 | pub dry_run: bool, 83 | } 84 | 85 | impl Args { 86 | pub fn update(&self) -> Option { 87 | exclusive_bool(("update", self.update), ("no-update", self.no_update)) 88 | } 89 | 90 | pub fn confirm(&self) -> Option { 91 | exclusive_bool(("confirm", self.confirm), ("no-confirm", self.no_confirm)) 92 | } 93 | 94 | pub fn detach(&self) -> Option { 95 | exclusive_bool(("detach", self.detach), ("no-detach", self.no_detach)) 96 | } 97 | } 98 | 99 | fn exclusive_bool( 100 | (name_pos, value_pos): (&str, bool), 101 | (name_neg, value_neg): (&str, bool), 102 | ) -> Option { 103 | if value_pos && value_neg { 104 | eprintln!( 105 | "Error: Flag '{}' and '{}' cannot be used simultaneously", 106 | name_pos, name_neg, 107 | ); 108 | exit(-1); 109 | } 110 | 111 | if value_pos { 112 | Some(true) 113 | } else if value_neg { 114 | Some(false) 115 | } else { 116 | None 117 | } 118 | } 119 | 120 | #[derive(Hash, Eq, PartialEq, Clone, Debug)] 121 | pub enum Scope { 122 | All, 123 | Scoped(String), 124 | } 125 | 126 | impl FromStr for Scope { 127 | type Err = ScopeParseError; 128 | 129 | fn from_str(s: &str) -> Result { 130 | match s.trim() { 131 | "" => Err(ScopeParseError { 132 | message: "Scope is empty".to_owned(), 133 | }), 134 | "*" => Ok(Scope::All), 135 | scope => Ok(Scope::Scoped(scope.to_owned())), 136 | } 137 | } 138 | } 139 | 140 | #[derive(Error, Debug)] 141 | #[error("{message}")] 142 | pub struct ScopeParseError { 143 | message: String, 144 | } 145 | 146 | #[derive(Hash, Eq, PartialEq, Clone, Debug)] 147 | pub enum DeleteRange { 148 | Merged(Scope), 149 | MergedLocal, 150 | MergedRemote(Scope), 151 | Stray, 152 | Diverged(Scope), 153 | Local, 154 | Remote(Scope), 155 | } 156 | 157 | #[derive(Hash, Eq, PartialEq, Clone, Debug)] 158 | pub enum DeleteUnit { 159 | MergedLocal, 160 | MergedRemote(Scope), 161 | Stray, 162 | Diverged(Scope), 163 | MergedNonTrackingLocal, 164 | MergedNonUpstreamRemoteTracking(Scope), 165 | } 166 | 167 | impl FromStr for DeleteRange { 168 | type Err = DeleteParseError; 169 | 170 | fn from_str(arg: &str) -> Result { 171 | let some_pair: Vec<_> = arg.splitn(2, ':').map(str::trim).collect(); 172 | match *some_pair.as_slice() { 173 | ["merged", remote] => Ok(DeleteRange::Merged(remote.parse()?)), 174 | ["stray"] => Ok(DeleteRange::Stray), 175 | ["diverged", remote] => Ok(DeleteRange::Diverged(remote.parse()?)), 176 | ["merged-local"] => Ok(DeleteRange::MergedLocal), 177 | ["merged-remote", remote] => Ok(DeleteRange::MergedRemote(remote.parse()?)), 178 | ["local"] => Ok(DeleteRange::Local), 179 | ["remote", remote] => Ok(DeleteRange::Remote(remote.parse()?)), 180 | _ => Err(DeleteParseError::InvalidDeleteRangeFormat(arg.to_owned())), 181 | } 182 | } 183 | } 184 | 185 | impl DeleteRange { 186 | fn to_delete_units(&self) -> Vec { 187 | match self { 188 | DeleteRange::Merged(scope) => vec![ 189 | DeleteUnit::MergedLocal, 190 | DeleteUnit::MergedRemote(scope.clone()), 191 | ], 192 | DeleteRange::MergedLocal => vec![DeleteUnit::MergedLocal], 193 | DeleteRange::MergedRemote(scope) => vec![DeleteUnit::MergedRemote(scope.clone())], 194 | DeleteRange::Stray => vec![DeleteUnit::Stray], 195 | DeleteRange::Diverged(scope) => vec![DeleteUnit::Diverged(scope.clone())], 196 | DeleteRange::Local => vec![DeleteUnit::MergedNonTrackingLocal], 197 | DeleteRange::Remote(scope) => { 198 | vec![DeleteUnit::MergedNonUpstreamRemoteTracking(scope.clone())] 199 | } 200 | } 201 | } 202 | 203 | pub fn merged_origin() -> Vec { 204 | use DeleteRange::*; 205 | vec![ 206 | MergedLocal, 207 | MergedRemote(Scope::Scoped("origin".to_string())), 208 | ] 209 | } 210 | } 211 | 212 | #[derive(Error, Debug)] 213 | pub enum DeleteParseError { 214 | #[error("Invalid delete range format `{0}`")] 215 | InvalidDeleteRangeFormat(String), 216 | #[error("Scope parse error for delete range while parsing scope: {0}")] 217 | ScopeParseError(#[from] ScopeParseError), 218 | } 219 | 220 | #[derive(Debug, Clone, Eq, PartialEq, Default)] 221 | pub struct DeleteFilter(HashSet); 222 | 223 | impl DeleteFilter { 224 | pub fn scan_tracking(&self) -> bool { 225 | self.0.iter().any(|unit| { 226 | matches!( 227 | unit, 228 | DeleteUnit::MergedLocal 229 | | DeleteUnit::MergedRemote(_) 230 | | DeleteUnit::Stray 231 | | DeleteUnit::Diverged(_) 232 | ) 233 | }) 234 | } 235 | 236 | pub fn scan_non_tracking_local(&self) -> bool { 237 | self.0.contains(&DeleteUnit::MergedNonTrackingLocal) 238 | } 239 | 240 | pub fn scan_non_upstream_remote(&self, remote: &str) -> bool { 241 | for unit in self.0.iter() { 242 | match unit { 243 | DeleteUnit::MergedNonUpstreamRemoteTracking(Scope::All) => return true, 244 | DeleteUnit::MergedNonUpstreamRemoteTracking(Scope::Scoped(specific)) 245 | if specific == remote => 246 | { 247 | return true 248 | } 249 | _ => {} 250 | } 251 | } 252 | false 253 | } 254 | 255 | pub fn delete_merged_local(&self) -> bool { 256 | self.0.contains(&DeleteUnit::MergedLocal) 257 | } 258 | 259 | pub fn delete_merged_remote(&self, remote: &str) -> bool { 260 | for unit in self.0.iter() { 261 | match unit { 262 | DeleteUnit::MergedRemote(Scope::All) => return true, 263 | DeleteUnit::MergedRemote(Scope::Scoped(specific)) if specific == remote => { 264 | return true 265 | } 266 | _ => {} 267 | } 268 | } 269 | false 270 | } 271 | 272 | pub fn delete_stray(&self) -> bool { 273 | self.0.contains(&DeleteUnit::Stray) 274 | } 275 | 276 | pub fn delete_diverged(&self, remote: &str) -> bool { 277 | for unit in self.0.iter() { 278 | match unit { 279 | DeleteUnit::Diverged(Scope::All) => return true, 280 | DeleteUnit::Diverged(Scope::Scoped(specific)) if specific == remote => return true, 281 | _ => {} 282 | } 283 | } 284 | false 285 | } 286 | 287 | pub fn delete_merged_non_tracking_local(&self) -> bool { 288 | self.0.contains(&DeleteUnit::MergedNonTrackingLocal) 289 | } 290 | 291 | pub fn delete_merged_non_upstream_remote_tracking(&self, remote: &str) -> bool { 292 | for filter in self.0.iter() { 293 | match filter { 294 | DeleteUnit::MergedNonUpstreamRemoteTracking(Scope::All) => return true, 295 | DeleteUnit::MergedNonUpstreamRemoteTracking(Scope::Scoped(specific)) 296 | if specific == remote => 297 | { 298 | return true 299 | } 300 | _ => {} 301 | } 302 | } 303 | false 304 | } 305 | } 306 | 307 | impl FromIterator for DeleteFilter { 308 | fn from_iter(iter: I) -> Self 309 | where 310 | I: IntoIterator, 311 | { 312 | use DeleteUnit::*; 313 | use Scope::*; 314 | 315 | let mut result = HashSet::new(); 316 | for unit in iter.into_iter() { 317 | match unit { 318 | MergedLocal | Stray | MergedNonTrackingLocal => { 319 | result.insert(unit.clone()); 320 | } 321 | MergedRemote(All) | Diverged(All) | MergedNonUpstreamRemoteTracking(All) => { 322 | result.retain(|x| discriminant(x) != discriminant(&unit)); 323 | result.insert(unit.clone()); 324 | } 325 | MergedRemote(_) => { 326 | if !result.contains(&MergedRemote(All)) { 327 | result.insert(unit.clone()); 328 | } 329 | } 330 | Diverged(_) => { 331 | if !result.contains(&Diverged(All)) { 332 | result.insert(unit.clone()); 333 | } 334 | } 335 | MergedNonUpstreamRemoteTracking(_) => { 336 | if !result.contains(&MergedNonUpstreamRemoteTracking(All)) { 337 | result.insert(unit.clone()); 338 | } 339 | } 340 | } 341 | } 342 | 343 | Self(result) 344 | } 345 | } 346 | 347 | impl FromIterator for DeleteFilter { 348 | fn from_iter(iter: I) -> Self 349 | where 350 | I: IntoIterator, 351 | { 352 | Self::from_iter(iter.into_iter().flat_map(|x| x.to_delete_units())) 353 | } 354 | } 355 | -------------------------------------------------------------------------------- /src/bin/build-man.rs: -------------------------------------------------------------------------------- 1 | use git_trim::args::Args; 2 | 3 | use clap::{Command, CommandFactory}; 4 | use man::prelude::*; 5 | 6 | fn main() { 7 | let command: Command = ::command(); 8 | 9 | let mut page = Manual::new(command.get_name()).flag( 10 | Flag::new() 11 | .short("-h") 12 | .long("--help") 13 | .help("Prints help information"), 14 | ); 15 | 16 | if let Some(about) = command.get_about() { 17 | page = page.about(about.to_string()); 18 | } 19 | 20 | for arg in command.get_arguments() { 21 | let hidden = arg.is_hide_set(); 22 | if hidden { 23 | continue; 24 | } 25 | 26 | let name = arg.get_id().as_str(); 27 | let short_help = arg.get_help(); 28 | let long_help = arg.get_long_help(); 29 | let help = match (short_help, long_help) { 30 | (None, None) => None, 31 | (Some(help), None) | (None, Some(help)) => Some(help), 32 | (Some(_), Some(long_help)) => Some(long_help), 33 | }; 34 | let short = arg.get_short(); 35 | let long = arg.get_long(); 36 | let flag = !arg.get_action().takes_values(); 37 | if flag { 38 | page = page.flag({ 39 | let mut flag = Flag::new(); 40 | if let Some(short) = short { 41 | flag = flag.short(&format!("-{}", short)) 42 | } 43 | if let Some(long) = long { 44 | flag = flag.long(&format!("--{}", long)); 45 | } 46 | if let Some(help) = help { 47 | flag = flag.help(&help.to_string()); 48 | } 49 | flag 50 | }); 51 | } else { 52 | page = page.option({ 53 | let mut opt = Opt::new(name); 54 | if let Some(short) = short { 55 | opt = opt.short(&format!("-{}", short)) 56 | } 57 | if let Some(long) = long { 58 | opt = opt.long(&format!("--{}", long)); 59 | } 60 | if let Some(help) = help { 61 | opt = opt.help(&help.to_string()); 62 | } 63 | opt 64 | }); 65 | } 66 | } 67 | 68 | println!("{}", page.render()); 69 | } 70 | -------------------------------------------------------------------------------- /src/branch.rs: -------------------------------------------------------------------------------- 1 | use std::convert::TryFrom; 2 | 3 | use anyhow::{Context, Result}; 4 | use git2::{Branch, Config, Direction, Reference, Repository}; 5 | use log::*; 6 | use thiserror::Error; 7 | 8 | use crate::config; 9 | use crate::simple_glob::{expand_refspec, ExpansionSide}; 10 | 11 | pub trait Refname { 12 | fn refname(&self) -> &str; 13 | } 14 | 15 | #[derive(Eq, PartialEq, Ord, PartialOrd, Debug, Hash, Clone)] 16 | pub struct LocalBranch { 17 | pub refname: String, 18 | } 19 | 20 | impl LocalBranch { 21 | pub fn new(refname: &str) -> Self { 22 | assert!(refname.starts_with("refs/heads/")); 23 | Self { 24 | refname: refname.to_string(), 25 | } 26 | } 27 | 28 | pub fn short_name(&self) -> &str { 29 | &self.refname["refs/heads/".len()..] 30 | } 31 | 32 | pub fn fetch_upstream( 33 | &self, 34 | repo: &Repository, 35 | config: &Config, 36 | ) -> Result { 37 | let remote_name = if let Some(remote_name) = config::get_remote_name(config, self)? { 38 | remote_name 39 | } else { 40 | return Ok(RemoteTrackingBranchStatus::None); 41 | }; 42 | let merge: String = if let Some(merge) = config::get_merge(config, self)? { 43 | merge 44 | } else { 45 | return Ok(RemoteTrackingBranchStatus::None); 46 | }; 47 | 48 | RemoteTrackingBranch::from_remote_branch( 49 | repo, 50 | &RemoteBranch { 51 | remote: remote_name, 52 | refname: merge, 53 | }, 54 | ) 55 | } 56 | } 57 | 58 | impl Refname for LocalBranch { 59 | fn refname(&self) -> &str { 60 | &self.refname 61 | } 62 | } 63 | 64 | impl<'repo> TryFrom<&git2::Branch<'repo>> for LocalBranch { 65 | type Error = anyhow::Error; 66 | 67 | fn try_from(branch: &Branch<'repo>) -> Result { 68 | let refname = branch.get().name().context("non-utf8 branch ref")?; 69 | Ok(Self::new(refname)) 70 | } 71 | } 72 | 73 | impl<'repo> TryFrom<&git2::Reference<'repo>> for LocalBranch { 74 | type Error = anyhow::Error; 75 | 76 | fn try_from(reference: &Reference<'repo>) -> Result { 77 | if !reference.is_branch() { 78 | anyhow::bail!("Reference {:?} is not a branch", reference.name()); 79 | } 80 | 81 | let refname = reference.name().context("non-utf8 reference name")?; 82 | Ok(Self::new(refname)) 83 | } 84 | } 85 | 86 | #[derive(Eq, PartialEq, Ord, PartialOrd, Debug, Hash, Clone)] 87 | pub struct RemoteTrackingBranch { 88 | pub refname: String, 89 | } 90 | 91 | impl RemoteTrackingBranch { 92 | pub fn new(refname: &str) -> RemoteTrackingBranch { 93 | assert!(refname.starts_with("refs/remotes/")); 94 | RemoteTrackingBranch { 95 | refname: refname.to_string(), 96 | } 97 | } 98 | 99 | pub fn from_remote_branch( 100 | repo: &Repository, 101 | remote_branch: &RemoteBranch, 102 | ) -> Result { 103 | let remote = config::get_remote(repo, &remote_branch.remote)?; 104 | if let Some(remote) = remote { 105 | let refname = if let Some(expanded) = expand_refspec( 106 | &remote, 107 | &remote_branch.refname, 108 | Direction::Fetch, 109 | ExpansionSide::Right, 110 | )? { 111 | expanded 112 | } else { 113 | return Ok(RemoteTrackingBranchStatus::None); 114 | }; 115 | 116 | if repo.find_reference(&refname).is_ok() { 117 | return Ok(RemoteTrackingBranchStatus::Exists( 118 | RemoteTrackingBranch::new(&refname), 119 | )); 120 | } else { 121 | return Ok(RemoteTrackingBranchStatus::Gone(refname)); 122 | } 123 | } 124 | Ok(RemoteTrackingBranchStatus::None) 125 | } 126 | 127 | pub fn to_remote_branch( 128 | &self, 129 | repo: &Repository, 130 | ) -> std::result::Result { 131 | for remote_name in repo.remotes()?.iter() { 132 | let remote_name = remote_name.context("non-utf8 remote name")?; 133 | let remote = repo.find_remote(remote_name)?; 134 | if let Some(expanded) = expand_refspec( 135 | &remote, 136 | &self.refname, 137 | Direction::Fetch, 138 | ExpansionSide::Left, 139 | )? { 140 | return Ok(RemoteBranch { 141 | remote: remote.name().context("non-utf8 remote name")?.to_string(), 142 | refname: expanded, 143 | }); 144 | } 145 | } 146 | Err(RemoteBranchError::RemoteNotFound) 147 | } 148 | } 149 | 150 | impl Refname for RemoteTrackingBranch { 151 | fn refname(&self) -> &str { 152 | &self.refname 153 | } 154 | } 155 | 156 | impl<'repo> TryFrom<&git2::Branch<'repo>> for RemoteTrackingBranch { 157 | type Error = anyhow::Error; 158 | 159 | fn try_from(branch: &Branch<'repo>) -> Result { 160 | let refname = branch.get().name().context("non-utf8 branch ref")?; 161 | Ok(Self::new(refname)) 162 | } 163 | } 164 | 165 | impl<'repo> TryFrom<&git2::Reference<'repo>> for RemoteTrackingBranch { 166 | type Error = anyhow::Error; 167 | 168 | fn try_from(reference: &Reference<'repo>) -> Result { 169 | if !reference.is_remote() { 170 | anyhow::bail!("Reference {:?} is not a branch", reference.name()); 171 | } 172 | 173 | let refname = reference.name().context("non-utf8 reference name")?; 174 | Ok(Self::new(refname)) 175 | } 176 | } 177 | 178 | pub enum RemoteTrackingBranchStatus { 179 | Exists(RemoteTrackingBranch), 180 | Gone(String), 181 | None, 182 | } 183 | 184 | #[derive(Eq, PartialEq, Ord, PartialOrd, Clone, Hash, Debug)] 185 | pub struct RemoteBranch { 186 | pub remote: String, 187 | pub refname: String, 188 | } 189 | 190 | impl std::fmt::Display for RemoteBranch { 191 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 192 | write!(f, "{}, {}", self.remote, self.refname) 193 | } 194 | } 195 | 196 | #[derive(Error, Debug)] 197 | pub enum RemoteBranchError { 198 | #[error("anyhow error")] 199 | AnyhowError(#[from] anyhow::Error), 200 | #[error("libgit2 internal error")] 201 | GitError(#[from] git2::Error), 202 | #[error("remote with matching refspec not found")] 203 | RemoteNotFound, 204 | } 205 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use std::convert::TryFrom; 2 | use std::fmt::Debug; 3 | use std::iter::FromIterator; 4 | use std::ops::Deref; 5 | use std::str::FromStr; 6 | 7 | use anyhow::{Context, Result}; 8 | use git2::{BranchType, Config as GitConfig, Error, ErrorClass, ErrorCode, Remote, Repository}; 9 | use log::*; 10 | 11 | use crate::args::{Args, DeleteFilter, DeleteRange}; 12 | use crate::branch::{LocalBranch, RemoteTrackingBranchStatus}; 13 | use std::collections::HashSet; 14 | 15 | type GitResult = std::result::Result; 16 | 17 | #[derive(Debug)] 18 | pub struct Config { 19 | pub bases: ConfigValue>, 20 | pub protected: ConfigValue>, 21 | pub update: ConfigValue, 22 | pub update_interval: ConfigValue, 23 | pub confirm: ConfigValue, 24 | pub detach: ConfigValue, 25 | pub delete: ConfigValue, 26 | } 27 | 28 | impl Config { 29 | pub fn read(repo: &Repository, config: &GitConfig, args: &Args) -> Result { 30 | fn non_empty(x: Vec) -> Option> { 31 | if x.is_empty() { 32 | None 33 | } else { 34 | Some(x) 35 | } 36 | } 37 | 38 | let bases = get_comma_separated_multi(config, "trim.bases") 39 | .with_explicit(non_empty(args.bases.clone())) 40 | .with_default(get_branches_tracks_remote_heads(repo, config)?) 41 | .parses_and_collect::>()?; 42 | let protected = get_comma_separated_multi(config, "trim.protected") 43 | .with_explicit(non_empty(args.protected.clone())) 44 | .parses_and_collect::>()?; 45 | let update = get(config, "trim.update") 46 | .with_explicit(args.update()) 47 | .with_default(true) 48 | .read()? 49 | .expect("has default"); 50 | let update_interval = get(config, "trim.updateInterval") 51 | .with_explicit(args.update_interval) 52 | .with_default(5) 53 | .read()? 54 | .expect("has default"); 55 | let confirm = get(config, "trim.confirm") 56 | .with_explicit(args.confirm()) 57 | .with_default(true) 58 | .read()? 59 | .expect("has default"); 60 | let detach = get(config, "trim.detach") 61 | .with_explicit(args.detach()) 62 | .with_default(true) 63 | .read()? 64 | .expect("has default"); 65 | let delete = get_comma_separated_multi(config, "trim.delete") 66 | .with_explicit(non_empty(args.delete.clone())) 67 | .with_default(DeleteRange::merged_origin()) 68 | .parses_and_collect::()?; 69 | 70 | Ok(Config { 71 | bases, 72 | protected, 73 | update, 74 | update_interval, 75 | confirm, 76 | detach, 77 | delete, 78 | }) 79 | } 80 | } 81 | 82 | fn get_branches_tracks_remote_heads(repo: &Repository, config: &GitConfig) -> Result> { 83 | let mut local_bases = Vec::new(); 84 | let mut all_bases = Vec::new(); 85 | 86 | for reference in repo.references_glob("refs/remotes/*/HEAD")? { 87 | let reference = reference?; 88 | // git symbolic-ref refs/remotes/*/HEAD 89 | let resolved = match reference.resolve() { 90 | Ok(resolved) => resolved, 91 | Err(_) => { 92 | debug!( 93 | "Reference {:?} is expected to be an symbolic ref, but it isn't", 94 | reference.name() 95 | ); 96 | continue; 97 | } 98 | }; 99 | let refname = resolved.name().context("non utf-8 reference name")?; 100 | all_bases.push(refname.to_owned()); 101 | 102 | for branch in repo.branches(Some(BranchType::Local))? { 103 | let (branch, _) = branch?; 104 | let branch = LocalBranch::try_from(&branch)?; 105 | 106 | if let RemoteTrackingBranchStatus::Exists(upstream) = 107 | branch.fetch_upstream(repo, config)? 108 | { 109 | if upstream.refname == refname { 110 | local_bases.push(branch.short_name().to_owned()); 111 | } 112 | } 113 | } 114 | } 115 | 116 | if local_bases.is_empty() { 117 | Ok(all_bases) 118 | } else { 119 | Ok(local_bases) 120 | } 121 | } 122 | 123 | #[derive(Debug, Eq, PartialEq)] 124 | pub enum ConfigValue { 125 | Explicit(T), 126 | GitConfig(T), 127 | Implicit(T), 128 | } 129 | 130 | impl ConfigValue { 131 | pub fn unwrap(self) -> T { 132 | match self { 133 | ConfigValue::Explicit(x) | ConfigValue::GitConfig(x) | ConfigValue::Implicit(x) => x, 134 | } 135 | } 136 | 137 | pub fn is_implicit(&self) -> bool { 138 | match self { 139 | ConfigValue::Explicit(_) => false, 140 | ConfigValue::GitConfig(_) => false, 141 | ConfigValue::Implicit(_) => true, 142 | } 143 | } 144 | } 145 | 146 | impl Deref for ConfigValue { 147 | type Target = T; 148 | 149 | fn deref(&self) -> &Self::Target { 150 | match self { 151 | ConfigValue::Explicit(x) | ConfigValue::GitConfig(x) | ConfigValue::Implicit(x) => x, 152 | } 153 | } 154 | } 155 | 156 | pub struct ConfigBuilder<'a, T> { 157 | config: &'a GitConfig, 158 | key: &'a str, 159 | explicit: Option, 160 | default: Option, 161 | comma_separated: bool, 162 | } 163 | 164 | pub fn get<'a, T>(config: &'a GitConfig, key: &'a str) -> ConfigBuilder<'a, T> { 165 | ConfigBuilder { 166 | config, 167 | key, 168 | explicit: None, 169 | default: None, 170 | comma_separated: false, 171 | } 172 | } 173 | 174 | pub fn get_comma_separated_multi<'a, T>( 175 | config: &'a GitConfig, 176 | key: &'a str, 177 | ) -> ConfigBuilder<'a, T> { 178 | ConfigBuilder { 179 | config, 180 | key, 181 | explicit: None, 182 | default: None, 183 | comma_separated: true, 184 | } 185 | } 186 | 187 | impl<'a, T> ConfigBuilder<'a, T> { 188 | fn with_explicit(self, value: Option) -> ConfigBuilder<'a, T> { 189 | if let Some(value) = value { 190 | ConfigBuilder { 191 | explicit: Some(value), 192 | ..self 193 | } 194 | } else { 195 | self 196 | } 197 | } 198 | 199 | pub fn with_default(self, value: T) -> ConfigBuilder<'a, T> { 200 | ConfigBuilder { 201 | default: Some(value), 202 | ..self 203 | } 204 | } 205 | } 206 | 207 | impl ConfigBuilder<'_, T> 208 | where 209 | T: ConfigValues, 210 | { 211 | pub fn read(self) -> GitResult>> { 212 | if let Some(value) = self.explicit { 213 | return Ok(Some(ConfigValue::Explicit(value))); 214 | } 215 | match T::get_config_value(self.config, self.key) { 216 | Ok(value) => Ok(Some(ConfigValue::GitConfig(value))), 217 | Err(err) if config_not_exist(&err) => { 218 | if let Some(default) = self.default { 219 | Ok(Some(ConfigValue::Implicit(default))) 220 | } else { 221 | Ok(None) 222 | } 223 | } 224 | Err(err) => Err(err), 225 | } 226 | } 227 | } 228 | 229 | impl ConfigBuilder<'_, T> { 230 | fn parses_and_collect(self) -> Result> 231 | where 232 | T: IntoIterator, 233 | T::Item: FromStr, 234 | ::Err: std::error::Error + Send + Sync + 'static, 235 | U: FromIterator<::Item> + Default, 236 | { 237 | if let Some(value) = self.explicit { 238 | return Ok(ConfigValue::Explicit(value.into_iter().collect())); 239 | } 240 | 241 | let result = match Vec::::get_config_value(self.config, self.key) { 242 | Ok(entries) if !entries.is_empty() => { 243 | let mut result = Vec::new(); 244 | if self.comma_separated { 245 | for entry in entries { 246 | for item in entry.split(',') { 247 | if !item.is_empty() { 248 | let value = ::from_str(item)?; 249 | result.push(value); 250 | } 251 | } 252 | } 253 | } else { 254 | for entry in entries { 255 | let value = ::from_str(&entry)?; 256 | result.push(value); 257 | } 258 | } 259 | 260 | ConfigValue::GitConfig(result.into_iter().collect()) 261 | } 262 | Ok(_) => { 263 | if let Some(default) = self.default { 264 | ConfigValue::Implicit(default.into_iter().collect()) 265 | } else { 266 | ConfigValue::Implicit(U::default()) 267 | } 268 | } 269 | Err(err) => return Err(err.into()), 270 | }; 271 | Ok(result) 272 | } 273 | } 274 | 275 | pub trait ConfigValues { 276 | fn get_config_value(config: &GitConfig, key: &str) -> Result 277 | where 278 | Self: Sized; 279 | } 280 | 281 | impl ConfigValues for String { 282 | fn get_config_value(config: &GitConfig, key: &str) -> Result { 283 | config.get_string(key) 284 | } 285 | } 286 | 287 | impl ConfigValues for Vec { 288 | fn get_config_value(config: &GitConfig, key: &str) -> Result { 289 | let mut result = Vec::new(); 290 | let mut entries = config.entries(Some(key))?; 291 | while let Some(entry) = entries.next() { 292 | let entry = entry?; 293 | if let Some(value) = entry.value() { 294 | result.push(value.to_owned()); 295 | } else { 296 | warn!( 297 | "non utf-8 config entry {}", 298 | String::from_utf8_lossy(entry.name_bytes()) 299 | ); 300 | } 301 | } 302 | Ok(result) 303 | } 304 | } 305 | 306 | impl ConfigValues for bool { 307 | fn get_config_value(config: &GitConfig, key: &str) -> Result { 308 | config.get_bool(key) 309 | } 310 | } 311 | 312 | impl ConfigValues for u64 { 313 | fn get_config_value(config: &GitConfig, key: &str) -> Result { 314 | let value = config.get_i64(key)?; 315 | if value >= 0 { 316 | return Ok(value as u64); 317 | } 318 | panic!("`git config {}` cannot be negative value", key); 319 | } 320 | } 321 | 322 | fn config_not_exist(err: &git2::Error) -> bool { 323 | err.code() == ErrorCode::NotFound && err.class() == ErrorClass::Config 324 | } 325 | 326 | pub fn get_push_remote(config: &GitConfig, branch: &LocalBranch) -> Result { 327 | let push_remote_key = format!("branch.{}.pushRemote", branch.short_name()); 328 | if let Some(push_remote) = get::(config, &push_remote_key).read()? { 329 | return Ok(push_remote.unwrap()); 330 | } 331 | 332 | if let Some(push_default) = get::(config, "remote.pushDefault").read()? { 333 | return Ok(push_default.unwrap()); 334 | } 335 | 336 | Ok(get_remote_name(config, branch)?.unwrap_or_else(|| "origin".to_owned())) 337 | } 338 | 339 | pub fn get_remote_name(config: &GitConfig, branch: &LocalBranch) -> Result> { 340 | let key = format!("branch.{}.remote", branch.short_name()); 341 | match config.get_string(&key) { 342 | Ok(remote) => Ok(Some(remote)), 343 | Err(err) if config_not_exist(&err) => Ok(None), 344 | Err(err) => Err(err.into()), 345 | } 346 | } 347 | 348 | pub fn get_remote<'a>(repo: &'a Repository, remote_name: &str) -> Result>> { 349 | fn error_is_missing_remote(err: &Error) -> bool { 350 | err.class() == ErrorClass::Config && err.code() == ErrorCode::InvalidSpec 351 | } 352 | 353 | match repo.find_remote(remote_name) { 354 | Ok(remote) => Ok(Some(remote)), 355 | Err(err) if error_is_missing_remote(&err) => Ok(None), 356 | Err(err) => Err(err.into()), 357 | } 358 | } 359 | 360 | pub fn get_merge(config: &GitConfig, branch: &LocalBranch) -> Result> { 361 | let key = format!("branch.{}.merge", branch.short_name()); 362 | match config.get_string(&key) { 363 | Ok(merge) => Ok(Some(merge)), 364 | Err(err) if config_not_exist(&err) => Ok(None), 365 | Err(err) => Err(err.into()), 366 | } 367 | } 368 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod args; 2 | mod branch; 3 | pub mod config; 4 | mod core; 5 | mod merge_tracker; 6 | mod simple_glob; 7 | mod subprocess; 8 | mod util; 9 | 10 | use std::collections::{HashMap, HashSet}; 11 | use std::convert::TryFrom; 12 | 13 | use anyhow::{Context, Result}; 14 | use git2::{Config as GitConfig, Error as GitError, ErrorCode, Repository}; 15 | use log::*; 16 | 17 | use crate::args::DeleteFilter; 18 | use crate::branch::RemoteTrackingBranchStatus; 19 | pub use crate::branch::{ 20 | LocalBranch, Refname, RemoteBranch, RemoteBranchError, RemoteTrackingBranch, 21 | }; 22 | use crate::core::{ 23 | get_direct_fetch_branches, get_non_tracking_local_branches, 24 | get_non_upstream_remote_tracking_branches, get_remote_heads, get_tracking_branches, Classifier, 25 | DirectFetchClassificationRequest, NonTrackingBranchClassificationRequest, 26 | NonUpstreamBranchClassificationRequest, TrackingBranchClassificationRequest, 27 | }; 28 | pub use crate::core::{ClassifiedBranch, SkipSuggestion, TrimPlan}; 29 | use crate::merge_tracker::MergeTracker; 30 | pub use crate::subprocess::{ls_remote_head, remote_update, RemoteHead}; 31 | pub use crate::util::ForceSendSync; 32 | 33 | pub struct Git { 34 | pub repo: Repository, 35 | pub config: GitConfig, 36 | } 37 | 38 | impl TryFrom for Git { 39 | type Error = GitError; 40 | 41 | fn try_from(repo: Repository) -> Result { 42 | let config = repo.config()?.snapshot()?; 43 | Ok(Self { repo, config }) 44 | } 45 | } 46 | 47 | pub struct PlanParam<'a> { 48 | pub bases: Vec<&'a str>, 49 | pub protected_patterns: Vec<&'a str>, 50 | pub delete: DeleteFilter, 51 | pub detach: bool, 52 | } 53 | 54 | pub fn get_trim_plan(git: &Git, param: &PlanParam) -> Result { 55 | let bases = resolve_bases(&git.repo, &git.config, ¶m.bases)?; 56 | let base_upstreams: Vec<_> = bases 57 | .iter() 58 | .map(|b| match b { 59 | BaseSpec::Local { upstream, .. } => upstream.clone(), 60 | BaseSpec::Remote { remote, .. } => remote.clone(), 61 | }) 62 | .collect(); 63 | trace!("bases: {:#?}", bases); 64 | 65 | let tracking_branches = get_tracking_branches(git)?; 66 | debug!("tracking_branches: {:#?}", tracking_branches); 67 | 68 | let direct_fetch_branches = get_direct_fetch_branches(git)?; 69 | debug!("direct_fetch_branches: {:#?}", direct_fetch_branches); 70 | 71 | let non_tracking_branches = get_non_tracking_local_branches(git)?; 72 | debug!("non_tracking_branches: {:#?}", non_tracking_branches); 73 | 74 | let non_upstream_branches = get_non_upstream_remote_tracking_branches(git)?; 75 | debug!("non_upstream_branches: {:#?}", non_upstream_branches); 76 | 77 | let remote_heads = if param.delete.scan_tracking() { 78 | let remotes: Vec<_> = direct_fetch_branches 79 | .iter() 80 | .map(|(_, r)| r.clone()) 81 | .collect(); 82 | get_remote_heads(git, &remotes)? 83 | } else { 84 | Vec::new() 85 | }; 86 | debug!("remote_heads: {:#?}", remote_heads); 87 | 88 | let merge_tracker = MergeTracker::with_base_upstreams(&git.repo, &git.config, &base_upstreams)?; 89 | let mut classifier = Classifier::new(git, &merge_tracker); 90 | let mut skipped = HashMap::new(); 91 | 92 | info!("Enqueue classification requests"); 93 | if param.delete.scan_tracking() { 94 | for (local, upstream) in &tracking_branches { 95 | for base in &base_upstreams { 96 | classifier.queue_request(TrackingBranchClassificationRequest { 97 | base, 98 | local, 99 | upstream: upstream.as_ref(), 100 | }); 101 | } 102 | } 103 | 104 | for (local, remote) in &direct_fetch_branches { 105 | for base in &base_upstreams { 106 | classifier.queue_request_with_context( 107 | DirectFetchClassificationRequest { 108 | base, 109 | local, 110 | remote, 111 | }, 112 | &remote_heads, 113 | ); 114 | } 115 | } 116 | } else { 117 | for (local, upstream) in &tracking_branches { 118 | if let Some(upstream) = upstream { 119 | let remote = upstream.to_remote_branch(&git.repo)?.remote; 120 | let suggestion = SkipSuggestion::TrackingRemote(remote); 121 | skipped.insert(local.refname.clone(), suggestion.clone()); 122 | skipped.insert(upstream.refname.clone(), suggestion.clone()); 123 | } else { 124 | skipped.insert(local.refname.clone(), SkipSuggestion::Tracking); 125 | } 126 | } 127 | 128 | for (local, _) in &direct_fetch_branches { 129 | skipped.insert(local.refname.clone(), SkipSuggestion::Tracking); 130 | } 131 | } 132 | 133 | if param.delete.scan_non_tracking_local() { 134 | for base in &base_upstreams { 135 | for local in &non_tracking_branches { 136 | classifier.queue_request(NonTrackingBranchClassificationRequest { base, local }); 137 | } 138 | } 139 | } else { 140 | for local in &non_tracking_branches { 141 | skipped.insert(local.refname.clone(), SkipSuggestion::NonTracking); 142 | } 143 | } 144 | 145 | for base in &base_upstreams { 146 | for remote_tracking in &non_upstream_branches { 147 | let remote = remote_tracking.to_remote_branch(&git.repo)?; 148 | if param.delete.scan_non_upstream_remote(&remote.remote) { 149 | classifier.queue_request(NonUpstreamBranchClassificationRequest { 150 | base, 151 | remote: remote_tracking, 152 | }); 153 | } else { 154 | let remote = remote_tracking.to_remote_branch(&git.repo)?.remote; 155 | skipped.insert( 156 | remote_tracking.refname.clone(), 157 | SkipSuggestion::NonUpstream(remote), 158 | ); 159 | } 160 | } 161 | } 162 | 163 | let classifications = classifier.classify()?; 164 | 165 | let mut result = TrimPlan { 166 | skipped, 167 | to_delete: HashSet::new(), 168 | preserved: Vec::new(), 169 | }; 170 | for classification in classifications { 171 | result.to_delete.extend(classification.result); 172 | } 173 | 174 | result.preserve_bases(&git.repo, &git.config, &bases)?; 175 | result.preserve_protected(&git.repo, ¶m.protected_patterns)?; 176 | result.preserve_non_heads_remotes(&git.repo)?; 177 | result.preserve_worktree(&git.repo)?; 178 | result.apply_delete_range_filter(&git.repo, ¶m.delete)?; 179 | 180 | if !param.detach { 181 | result.adjust_not_to_detach(&git.repo)?; 182 | } 183 | 184 | Ok(result) 185 | } 186 | 187 | #[derive(Debug)] 188 | pub(crate) enum BaseSpec<'a> { 189 | Local { 190 | #[allow(dead_code)] // used in `Debug` 191 | pattern: &'a str, 192 | local: LocalBranch, 193 | upstream: RemoteTrackingBranch, 194 | }, 195 | Remote { 196 | pattern: &'a str, 197 | remote: RemoteTrackingBranch, 198 | }, 199 | } 200 | 201 | impl BaseSpec<'_> { 202 | fn is_local(&self, branch: &LocalBranch) -> bool { 203 | matches!(self, BaseSpec::Local { local, .. } if local == branch) 204 | } 205 | 206 | fn covers_remote(&self, refname: &str) -> bool { 207 | match self { 208 | BaseSpec::Local { upstream, .. } if upstream.refname() == refname => true, 209 | BaseSpec::Remote { remote, .. } if remote.refname() == refname => true, 210 | _ => false, 211 | } 212 | } 213 | 214 | fn remote_pattern(&self, refname: &str) -> Option<&str> { 215 | match self { 216 | BaseSpec::Remote { pattern, remote } if remote.refname() == refname => Some(pattern), 217 | _ => None, 218 | } 219 | } 220 | } 221 | 222 | pub(crate) fn resolve_bases<'a>( 223 | repo: &Repository, 224 | config: &GitConfig, 225 | bases: &[&'a str], 226 | ) -> Result>> { 227 | let mut result = Vec::new(); 228 | for base in bases { 229 | let reference = match repo.resolve_reference_from_short_name(base) { 230 | Ok(reference) => reference, 231 | Err(err) if err.code() == ErrorCode::NotFound => continue, 232 | Err(err) => return Err(err.into()), 233 | }; 234 | 235 | if reference.is_branch() { 236 | let local = LocalBranch::try_from(&reference)?; 237 | if let RemoteTrackingBranchStatus::Exists(upstream) = 238 | local.fetch_upstream(repo, config)? 239 | { 240 | result.push(BaseSpec::Local { 241 | pattern: base, 242 | local, 243 | upstream, 244 | }) 245 | } 246 | } else { 247 | let remote = RemoteTrackingBranch::try_from(&reference)?; 248 | result.push(BaseSpec::Remote { 249 | pattern: base, 250 | remote, 251 | }) 252 | } 253 | } 254 | 255 | Ok(result) 256 | } 257 | 258 | pub fn delete_local_branches( 259 | repo: &Repository, 260 | branches: &[&LocalBranch], 261 | dry_run: bool, 262 | ) -> Result<()> { 263 | if branches.is_empty() { 264 | return Ok(()); 265 | } 266 | 267 | let detach_to = if repo.head_detached()? { 268 | None 269 | } else { 270 | let head = repo.head()?; 271 | let head_refname = head.name().context("non-utf8 head ref name")?; 272 | if branches.iter().any(|branch| branch.refname == head_refname) { 273 | Some(head) 274 | } else { 275 | None 276 | } 277 | }; 278 | 279 | if let Some(head) = detach_to { 280 | subprocess::checkout(repo, head, dry_run)?; 281 | } 282 | subprocess::branch_delete(repo, branches, dry_run)?; 283 | 284 | Ok(()) 285 | } 286 | 287 | pub fn delete_remote_branches( 288 | repo: &Repository, 289 | remote_branches: &[RemoteBranch], 290 | dry_run: bool, 291 | ) -> Result<()> { 292 | if remote_branches.is_empty() { 293 | return Ok(()); 294 | } 295 | let mut per_remote = HashMap::new(); 296 | for remote_branch in remote_branches { 297 | let entry = per_remote 298 | .entry(&remote_branch.remote) 299 | .or_insert_with(Vec::new); 300 | entry.push(remote_branch); 301 | } 302 | for (remote_name, remote_refnames) in per_remote.iter() { 303 | subprocess::push_delete(repo, remote_name, remote_refnames, dry_run)?; 304 | } 305 | Ok(()) 306 | } 307 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod remote_head_change_checker; 2 | 3 | use std::collections::HashSet; 4 | use std::convert::TryFrom; 5 | use std::iter::FromIterator; 6 | 7 | use anyhow::{Context, Result}; 8 | use clap::Parser; 9 | use dialoguer::Confirm; 10 | use git2::{BranchType, Repository}; 11 | use log::*; 12 | 13 | use git_trim::args::Args; 14 | use git_trim::config::{self, get, Config, ConfigValue}; 15 | use git_trim::{ 16 | delete_local_branches, delete_remote_branches, get_trim_plan, ls_remote_head, remote_update, 17 | ClassifiedBranch, ForceSendSync, Git, LocalBranch, PlanParam, RemoteHead, RemoteTrackingBranch, 18 | SkipSuggestion, TrimPlan, 19 | }; 20 | 21 | fn main() -> Result<()> { 22 | let args = Args::parse(); 23 | 24 | env_logger::init(); 25 | if let Some(version) = option_env!("VERGEN_GIT_DESCRIBE") { 26 | info!("VERSION: {version}"); 27 | } else { 28 | info!("VERSION: {}", env!("CARGO_PKG_VERSION")); 29 | } 30 | if let Some(commit_date) = option_env!("VERGEN_GIT_COMMIT_TIMESTAMP") { 31 | info!("COMMIT_DATE: {commit_date}"); 32 | } 33 | info!("TARGET_TRIPLE: {}", env!("VERGEN_CARGO_TARGET_TRIPLE")); 34 | 35 | let git = Git::try_from(Repository::open_from_env()?)?; 36 | 37 | if git.repo.remotes()?.is_empty() { 38 | return Err(anyhow::anyhow!("git-trim requires at least one remote")); 39 | } 40 | 41 | let config = Config::read(&git.repo, &git.config, &args)?; 42 | info!("config: {:?}", config); 43 | if config.bases.is_empty() { 44 | return error_no_bases(&git.repo, &config.bases); 45 | } 46 | 47 | let mut checker = None; 48 | if *config.update { 49 | if should_update(&git, *config.update_interval, config.update)? { 50 | checker = Some(remote_head_change_checker::RemoteHeadChangeChecker::spawn()?); 51 | remote_update(&git.repo, args.dry_run)?; 52 | println!(); 53 | } else { 54 | println!("Repository is updated recently. Skip to update it") 55 | } 56 | } 57 | 58 | let plan = get_trim_plan( 59 | &git, 60 | &PlanParam { 61 | bases: config.bases.iter().map(String::as_str).collect(), 62 | protected_patterns: config.protected.iter().map(String::as_str).collect(), 63 | delete: config.delete.clone(), 64 | detach: *config.detach, 65 | }, 66 | )?; 67 | 68 | print_summary(&plan, &git.repo)?; 69 | 70 | let locals = plan.locals_to_delete(); 71 | let remotes = plan.remotes_to_delete(&git.repo)?; 72 | let any_branches_to_remove = !(locals.is_empty() && remotes.is_empty()); 73 | 74 | if !args.dry_run 75 | && *config.confirm 76 | && any_branches_to_remove 77 | && !Confirm::new() 78 | .with_prompt("Confirm?") 79 | .default(false) 80 | .interact()? 81 | { 82 | println!("Cancelled"); 83 | return Ok(()); 84 | } 85 | 86 | delete_remote_branches(&git.repo, remotes.as_slice(), args.dry_run)?; 87 | delete_local_branches(&git.repo, &locals, args.dry_run)?; 88 | 89 | prompt_survey_on_push_upstream(&git)?; 90 | 91 | if let Some(checker) = checker.take() { 92 | checker.check_and_notify(&git.repo)?; 93 | } 94 | Ok(()) 95 | } 96 | 97 | fn error_no_bases(repo: &Repository, bases: &ConfigValue>) -> Result<()> { 98 | fn eprint_bullet(s: &str) { 99 | let width = textwrap::termwidth().max(40) - 4; 100 | for (i, line) in textwrap::wrap(s, width).iter().enumerate() { 101 | if i == 0 { 102 | eprintln!(" * {}", line); 103 | } else { 104 | eprintln!(" {}", line); 105 | } 106 | } 107 | } 108 | const GENERAL_HELP: &[&str] = &[ 109 | "`git config trim.bases develop,master` for a repository.", 110 | "`git config --global trim.bases develop,master` to set globally.", 111 | "`git trim --bases develop,master` to set temporarily.", 112 | ]; 113 | match bases { 114 | ConfigValue::Explicit(_) => { 115 | eprintln!( 116 | "I found that you passed an empty value to the CLI option `--bases`. Don't do that." 117 | ); 118 | } 119 | ConfigValue::GitConfig(_) => { 120 | eprintln!( 121 | "I found that `git config trim.bases` is empty! Try any following commands to set valid bases:" 122 | ); 123 | for help in GENERAL_HELP { 124 | eprint_bullet(help); 125 | } 126 | } 127 | ConfigValue::Implicit(_) => { 128 | let remotes = repo.remotes()?; 129 | let remotes: Vec<_> = remotes.iter().collect(); 130 | if remotes.len() == 1 { 131 | let remote = remotes[0].expect("non utf-8 remote name"); 132 | eprintln!("I can't detect base branch! Try following any resolution:"); 133 | eprint_bullet(&format!( 134 | "\ 135 | `git remote set-head {remote} --auto` will help `git-trim` to automatically detect the base branch. 136 | If you see `{remote}/HEAD set to ` in the output of the previous command, \ 137 | then `git branch --set-upstream {remote}/ ` to set an upstream branch for if exists.", 138 | remote = remote 139 | )); 140 | } else { 141 | eprintln!("I can't detect base branch! Try following any resolution:"); 142 | eprint_bullet( 143 | "\ 144 | `git remote set-head --auto` will help `git-trim` to automatically detect the base branch. 145 | Following command will sync all remotes for you: 146 | `for REMOTE in $(git remote); do git remote set-head \"$REMOTE\" --auto; done` 147 | Pick an appropriate one in mind if you see multiple `/HEAD set to ` in the output of the previous command. 148 | Then `git branch --set-upstream / ` to set an upstream branch for if exists.", 149 | ); 150 | } 151 | println!("You also can set bases manually with any of following commands:"); 152 | for help in GENERAL_HELP { 153 | eprint_bullet(help); 154 | } 155 | } 156 | } 157 | 158 | Err(anyhow::anyhow!("No base branch is found!")) 159 | } 160 | 161 | pub fn print_summary(plan: &TrimPlan, repo: &Repository) -> Result<()> { 162 | println!("Branches that will remain:"); 163 | println!(" local branches:"); 164 | let local_branches_to_delete = HashSet::<_>::from_iter(plan.locals_to_delete()); 165 | for local_branch in repo.branches(Some(BranchType::Local))? { 166 | let (branch, _) = local_branch?; 167 | let branch_name = branch.name()?.context("non utf-8 local branch name")?; 168 | let refname = branch.get().name().context("non utf-8 local refname")?; 169 | let branch = LocalBranch::new(refname); 170 | if local_branches_to_delete.contains(&branch) { 171 | continue; 172 | } 173 | if let Some(preserved) = plan.get_preserved_local(&branch) { 174 | if preserved.base && matches!(preserved.branch, ClassifiedBranch::MergedLocal(_)) { 175 | println!(" {} [{}]", branch_name, preserved.reason); 176 | } else { 177 | println!( 178 | " {} [{}, but: {}]", 179 | branch_name, 180 | preserved.branch.message_local(), 181 | preserved.reason 182 | ); 183 | } 184 | } else if let Some(suggestion) = plan.skipped.get(refname) { 185 | println!(" {} *{}", branch_name, suggestion.kind()); 186 | } else { 187 | println!(" {}", branch_name); 188 | } 189 | } 190 | println!(" remote references:"); 191 | let remote_refs_to_delete = HashSet::<_>::from_iter(plan.remotes_to_delete(repo)?); 192 | let mut printed_remotes = HashSet::new(); 193 | for remote_ref in repo.branches(Some(BranchType::Remote))? { 194 | let (branch, _) = remote_ref?; 195 | if branch.get().symbolic_target_bytes().is_some() { 196 | continue; 197 | } 198 | let refname = branch.get().name().context("non utf-8 remote ref name")?; 199 | let shorthand = branch 200 | .get() 201 | .shorthand() 202 | .context("non utf-8 remote ref name")?; 203 | let upstream = RemoteTrackingBranch::new(refname); 204 | let remote_branch = upstream.to_remote_branch(repo)?; 205 | if remote_refs_to_delete.contains(&remote_branch) { 206 | continue; 207 | } 208 | if let Some(preserved) = plan.get_preserved_upstream(&upstream) { 209 | if preserved.base 210 | && matches!(preserved.branch, ClassifiedBranch::MergedRemoteTracking(_)) 211 | { 212 | println!(" {} [{}]", shorthand, preserved.reason); 213 | } else { 214 | println!( 215 | " {} [{}, but: {}]", 216 | shorthand, 217 | preserved.branch.message_remote(), 218 | preserved.reason 219 | ); 220 | } 221 | } else if let Some(suggestion) = plan.skipped.get(refname) { 222 | println!(" {} *{}", shorthand, suggestion.kind()); 223 | } else { 224 | println!(" {}", shorthand); 225 | } 226 | printed_remotes.insert(remote_branch); 227 | } 228 | for preserved in &plan.preserved { 229 | match &preserved.branch { 230 | ClassifiedBranch::MergedDirectFetch { remote, .. } 231 | | ClassifiedBranch::DivergedDirectFetch { remote, .. } => { 232 | println!( 233 | " {} [{}, but: {}]", 234 | remote, 235 | preserved.branch.message_remote(), 236 | preserved.reason, 237 | ); 238 | } 239 | _ => {} 240 | } 241 | } 242 | 243 | if !plan.skipped.is_empty() { 244 | println!(" Some branches are skipped. Consider following to scan them:"); 245 | let tracking = plan 246 | .skipped 247 | .values() 248 | .any(|suggest| suggest == &SkipSuggestion::Tracking); 249 | let tracking_remotes: Vec<_> = { 250 | let mut tmp = Vec::new(); 251 | for suggest in plan.skipped.values() { 252 | if let SkipSuggestion::TrackingRemote(r) = suggest { 253 | tmp.push(r); 254 | } 255 | } 256 | tmp 257 | }; 258 | if let [single] = tracking_remotes.as_slice() { 259 | println!( 260 | " *{}: Add `--delete 'merged:{}'` flag.", 261 | SkipSuggestion::KIND_TRACKING, 262 | single 263 | ); 264 | } else if tracking_remotes.len() > 1 { 265 | println!( 266 | " *{}: Add `--delete 'merged:*'` flag.", 267 | SkipSuggestion::KIND_TRACKING, 268 | ); 269 | } else if tracking { 270 | println!( 271 | " *{}: Add `--delete 'merged-local'` flag.", 272 | SkipSuggestion::KIND_TRACKING, 273 | ); 274 | } 275 | let non_tracking = plan 276 | .skipped 277 | .values() 278 | .any(|suggest| suggest == &SkipSuggestion::NonTracking); 279 | if non_tracking { 280 | println!( 281 | " *{}: Set an upstream to make it a tracking branch or add `--delete 'local'` flag.", 282 | SkipSuggestion::KIND_NON_TRACKING, 283 | ); 284 | } 285 | 286 | let non_upstream_remotes: Vec<_> = { 287 | let mut tmp = Vec::new(); 288 | for suggest in plan.skipped.values() { 289 | if let SkipSuggestion::NonUpstream(r) = suggest { 290 | tmp.push(r); 291 | } 292 | } 293 | tmp 294 | }; 295 | if let [single] = non_upstream_remotes.as_slice() { 296 | println!( 297 | " *{}: Make it upstream of a tracking branch or add `--delete 'remote:{}'` flag.", 298 | SkipSuggestion::KIND_NON_UPSTREAM, 299 | single 300 | ); 301 | } else if non_upstream_remotes.len() > 1 { 302 | println!( 303 | " *{}: Make it upstream of a tracking branch or add `--delete 'remote:*'` flag.", 304 | SkipSuggestion::KIND_NON_UPSTREAM, 305 | ); 306 | } 307 | } 308 | println!(); 309 | 310 | let mut merged_locals = Vec::new(); 311 | let mut merged_remotes = Vec::new(); 312 | let mut stray = Vec::new(); 313 | let mut diverged_remotes = Vec::new(); 314 | for branch in &plan.to_delete { 315 | match branch { 316 | ClassifiedBranch::MergedLocal(local) => { 317 | merged_locals.push(local.short_name().to_owned()) 318 | } 319 | ClassifiedBranch::Stray(local) => stray.push(local.short_name().to_owned()), 320 | ClassifiedBranch::MergedRemoteTracking(upstream) => { 321 | let remote = upstream.to_remote_branch(repo)?; 322 | merged_remotes.push(remote.to_string()) 323 | } 324 | ClassifiedBranch::DivergedRemoteTracking { local, upstream } => { 325 | let remote = upstream.to_remote_branch(repo)?; 326 | merged_locals.push(local.short_name().to_owned()); 327 | diverged_remotes.push(remote.to_string()) 328 | } 329 | ClassifiedBranch::MergedDirectFetch { local, remote } 330 | | ClassifiedBranch::DivergedDirectFetch { local, remote } => { 331 | merged_locals.push(local.short_name().to_owned()); 332 | diverged_remotes.push(remote.to_string()) 333 | } 334 | ClassifiedBranch::MergedNonTrackingLocal(local) => { 335 | merged_locals.push(format!("{} (non-tracking)", local.short_name())); 336 | } 337 | ClassifiedBranch::MergedNonUpstreamRemoteTracking(upstream) => { 338 | let remote = upstream.to_remote_branch(repo)?; 339 | merged_remotes.push(format!("{} (non-upstream)", remote)); 340 | } 341 | } 342 | } 343 | 344 | fn print(label: &str, mut branches: Vec) -> Result<()> { 345 | if branches.is_empty() { 346 | return Ok(()); 347 | } 348 | branches.sort(); 349 | println!("Delete {}:", label); 350 | for branch in branches { 351 | println!(" - {}", branch); 352 | } 353 | Ok(()) 354 | } 355 | 356 | print("merged local branches", merged_locals)?; 357 | print("merged remote refs", merged_remotes)?; 358 | print("stray local branches", stray)?; 359 | print("diverged remote refs", diverged_remotes)?; 360 | 361 | Ok(()) 362 | } 363 | 364 | fn should_update(git: &Git, interval: u64, config_update: ConfigValue) -> Result { 365 | if interval == 0 { 366 | return Ok(true); 367 | } 368 | 369 | if matches!(config_update, ConfigValue::Explicit(true)) { 370 | trace!("explicitly set --update. force update"); 371 | return Ok(true); 372 | } 373 | 374 | let auto_prune = config::get(&git.config, "fetch.prune") 375 | .with_default(false) 376 | .read()? 377 | .expect("default is provided"); 378 | if !*auto_prune { 379 | trace!("`git config fetch.prune` is false. force update"); 380 | return Ok(true); 381 | } 382 | 383 | let fetch_head = git.repo.path().join("FETCH_HEAD"); 384 | if !fetch_head.exists() { 385 | return Ok(true); 386 | } 387 | 388 | let metadata = std::fs::metadata(fetch_head)?; 389 | let elapsed = match metadata.modified()?.elapsed() { 390 | Ok(elapsed) => elapsed, 391 | Err(_) => return Ok(true), 392 | }; 393 | 394 | Ok(elapsed.as_secs() >= interval) 395 | } 396 | 397 | fn prompt_survey_on_push_upstream(git: &Git) -> Result<()> { 398 | for remote_name in git.repo.remotes()?.iter() { 399 | let remote_name = remote_name.context("non-utf8 remote name")?; 400 | let key = format!("remote.{}.push", remote_name); 401 | if get::(&git.config, &key).read()?.is_some() { 402 | println!( 403 | r#" 404 | 405 | Help wanted! 406 | I recognize that you've set a config `git config remote.{}.push`! 407 | I once (mis)used that config to classify branches, but I retracted it after realizing that I don't understand the config well. 408 | It would be very helpful to me if you share your use cases of the config to me. 409 | Here's the survey URL: https://github.com/foriequal0/git-trim/issues/134 410 | Thank you! 411 | "#, 412 | remote_name 413 | ); 414 | break; 415 | } 416 | } 417 | Ok(()) 418 | } 419 | -------------------------------------------------------------------------------- /src/merge_tracker.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | use std::fmt::Debug; 3 | use std::sync::{Arc, Mutex}; 4 | 5 | use anyhow::Result; 6 | use git2::{Config, ErrorClass, ErrorCode, Oid, Repository, Signature}; 7 | use log::*; 8 | 9 | use crate::branch::{Refname, RemoteTrackingBranch}; 10 | use crate::subprocess::{self, is_merged_by_rev_list}; 11 | 12 | #[derive(Clone)] 13 | pub struct MergeTracker { 14 | merged_set: Arc>>, 15 | } 16 | 17 | #[derive(Debug, Clone)] 18 | pub struct MergeState { 19 | pub branch: B, 20 | pub commit: String, 21 | pub merged: bool, 22 | } 23 | 24 | impl MergeTracker { 25 | pub fn with_base_upstreams( 26 | repo: &Repository, 27 | config: &Config, 28 | base_upstreams: &[RemoteTrackingBranch], 29 | ) -> Result { 30 | let tracker = Self { 31 | merged_set: Arc::new(Mutex::new(HashSet::new())), 32 | }; 33 | info!("Initializing MergeTracker"); 34 | for base_upstream in base_upstreams { 35 | debug!("base_upstream: {:?}", base_upstream); 36 | tracker.track(repo, base_upstream)?; 37 | } 38 | 39 | for merged_local in subprocess::get_noff_merged_locals(repo, config, base_upstreams)? { 40 | debug!("merged_local: {:?}", merged_local); 41 | tracker.track(repo, &merged_local)?; 42 | } 43 | 44 | for merged_remote in subprocess::get_noff_merged_remotes(repo, base_upstreams)? { 45 | debug!("merged_remote: {:?}", merged_remote); 46 | tracker.track(repo, &merged_remote)?; 47 | } 48 | 49 | Ok(tracker) 50 | } 51 | 52 | pub fn track(&self, repo: &Repository, branch: &T) -> Result<()> 53 | where 54 | T: Refname, 55 | { 56 | let oid = repo 57 | .find_reference(branch.refname())? 58 | .peel_to_commit()? 59 | .id() 60 | .to_string(); 61 | let mut set = self.merged_set.lock().unwrap(); 62 | trace!("track: {}", oid); 63 | set.insert(oid); 64 | Ok(()) 65 | } 66 | 67 | pub fn check_and_track( 68 | &self, 69 | repo: &Repository, 70 | base: &str, 71 | branch: &T, 72 | ) -> Result> 73 | where 74 | T: Refname + Clone, 75 | { 76 | let base_commit_id = repo.find_reference(base)?.peel_to_commit()?.id(); 77 | let target_commit_id = repo 78 | .find_reference(branch.refname())? 79 | .peel_to_commit()? 80 | .id(); 81 | let target_commit_id_string = target_commit_id.to_string(); 82 | 83 | // I know the locking is ugly. I'm trying to hold the lock as short as possible. 84 | // Operations against `repo` take long time up to several seconds when the disk is slow. 85 | { 86 | let set = self.merged_set.lock().unwrap().clone(); 87 | if set.contains(&target_commit_id_string) { 88 | debug!( 89 | "tracked: {} ({})", 90 | &target_commit_id_string[0..7], 91 | branch.refname(), 92 | ); 93 | return Ok(MergeState { 94 | merged: true, 95 | commit: target_commit_id_string, 96 | branch: branch.clone(), 97 | }); 98 | } 99 | 100 | for merged in set.iter() { 101 | let merged_oid = Oid::from_str(merged)?; 102 | // B A 103 | // *--*--* 104 | // / \ 105 | // *--*--*--*--* base 106 | // In this diagram, `$(git merge-base A B) == B`. 107 | // When we're sure that A is merged into base, then we can safely conclude that 108 | // B is also merged into base. 109 | let noff_merged = match repo.merge_base(merged_oid, target_commit_id) { 110 | Ok(merge_base) if merge_base == target_commit_id => { 111 | let mut set = self.merged_set.lock().unwrap(); 112 | set.insert(target_commit_id_string.clone()); 113 | true 114 | } 115 | Ok(_) => continue, 116 | Err(err) if merge_base_not_found(&err) => false, 117 | Err(err) => return Err(err.into()), 118 | }; 119 | debug!("noff merged: ({}) -> {}", branch.refname(), &merged[0..7]); 120 | return Ok(MergeState { 121 | merged: noff_merged, 122 | commit: target_commit_id_string, 123 | branch: branch.clone(), 124 | }); 125 | } 126 | } 127 | 128 | fn merge_base_not_found(err: &git2::Error) -> bool { 129 | err.class() == ErrorClass::Merge && err.code() == ErrorCode::NotFound 130 | } 131 | 132 | if is_merged_by_rev_list(repo, base, branch.refname())? { 133 | let mut set = self.merged_set.lock().unwrap(); 134 | set.insert(target_commit_id_string.clone()); 135 | debug!("rebase merged: {} -> {}", branch.refname(), &base); 136 | return Ok(MergeState { 137 | merged: true, 138 | commit: target_commit_id_string, 139 | branch: branch.clone(), 140 | }); 141 | } 142 | 143 | let squash_merged = match repo.merge_base(base_commit_id, target_commit_id) { 144 | Ok(merge_base) => { 145 | let merge_base = merge_base.to_string(); 146 | let squash_merged = is_squash_merged(repo, &merge_base, base, branch.refname())?; 147 | if squash_merged { 148 | let mut set = self.merged_set.lock().unwrap(); 149 | set.insert(target_commit_id_string.clone()); 150 | } 151 | squash_merged 152 | } 153 | Err(err) if merge_base_not_found(&err) => false, 154 | Err(err) => return Err(err.into()), 155 | }; 156 | 157 | if squash_merged { 158 | debug!("squash merged: {} -> {}", branch.refname(), &base); 159 | } 160 | Ok(MergeState { 161 | merged: squash_merged, 162 | commit: target_commit_id_string, 163 | branch: branch.clone(), 164 | }) 165 | } 166 | } 167 | 168 | /// Source: https://stackoverflow.com/a/56026209 169 | fn is_squash_merged( 170 | repo: &Repository, 171 | merge_base: &str, 172 | base: &str, 173 | refname: &str, 174 | ) -> Result { 175 | let tree = repo 176 | .revparse_single(&format!("{}^{{tree}}", refname))? 177 | .peel_to_tree()?; 178 | let tmp_sig = Signature::now("git-trim", "git-trim@squash.merge.test.local")?; 179 | let dangling_commit = repo.commit( 180 | None, 181 | &tmp_sig, 182 | &tmp_sig, 183 | "git-trim: squash merge test", 184 | &tree, 185 | &[&repo.find_commit(Oid::from_str(merge_base)?)?], 186 | )?; 187 | 188 | is_merged_by_rev_list(repo, base, &dangling_commit.to_string()) 189 | } 190 | -------------------------------------------------------------------------------- /src/remote_head_change_checker.rs: -------------------------------------------------------------------------------- 1 | use std::thread::JoinHandle; 2 | 3 | use anyhow::{Context, Result}; 4 | use git2::Repository; 5 | use log::*; 6 | use rayon::prelude::*; 7 | 8 | use crate::{ls_remote_head, ForceSendSync, RemoteHead, RemoteTrackingBranch}; 9 | 10 | pub struct RemoteHeadChangeChecker { 11 | join_handle: JoinHandle>>, 12 | } 13 | 14 | impl RemoteHeadChangeChecker { 15 | pub fn spawn() -> Result { 16 | let join_handle = { 17 | let repo = ForceSendSync::new(Repository::open_from_env()?); 18 | let remotes = { 19 | let mut tmp = Vec::new(); 20 | for remote_name in repo.remotes()?.iter() { 21 | let remote_name = remote_name.context("non-utf8 remote name")?; 22 | tmp.push(remote_name.to_owned()) 23 | } 24 | tmp 25 | }; 26 | std::thread::spawn(move || { 27 | remotes 28 | .par_iter() 29 | .map(|remote_name| ls_remote_head(&repo, remote_name)) 30 | .collect() 31 | }) 32 | }; 33 | Ok(Self { join_handle }) 34 | } 35 | 36 | pub fn check_and_notify(self, repo: &Repository) -> Result<()> { 37 | let fetched_remote_heads_raw = self.join_handle.join().unwrap()?; 38 | let mut fetched_remote_heads: Vec = Vec::new(); 39 | for remote_head in fetched_remote_heads_raw.into_iter() { 40 | fetched_remote_heads.push(remote_head); 41 | } 42 | 43 | let mut out_of_sync = Vec::new(); 44 | for reference in repo.references_glob("refs/remotes/*/HEAD")? { 45 | let reference = reference?; 46 | // git symbolic-ref refs/remotes/*/HEAD 47 | let resolved = match reference.resolve() { 48 | Ok(resolved) => resolved, 49 | Err(_) => { 50 | debug!( 51 | "Reference {:?} is expected to be an symbolic ref, but it isn't", 52 | reference.name() 53 | ); 54 | continue; 55 | } 56 | }; 57 | let refname = resolved.name().context("non utf-8 reference name")?; 58 | 59 | let remote_head = RemoteTrackingBranch::new(refname).to_remote_branch(repo)?; 60 | 61 | let fetch_remote_head = fetched_remote_heads 62 | .iter() 63 | .find(|x| x.remote == remote_head.remote); 64 | if let Some(fetched_remote_head) = fetch_remote_head { 65 | let matches = fetched_remote_heads 66 | .iter() 67 | .any(|x| x.remote == remote_head.remote && x.refname == remote_head.refname); 68 | if !matches { 69 | out_of_sync.push((remote_head, fetched_remote_head)) 70 | } 71 | } 72 | } 73 | 74 | if out_of_sync.is_empty() { 75 | return Ok(()); 76 | } 77 | 78 | eprintln!( 79 | "You are using default base branches, which is deduced from `refs/remotes/*/HEAD`s." 80 | ); 81 | eprintln!("However, they seems to be out of sync."); 82 | for (remote_head, fetched_remote_head) in &out_of_sync { 83 | eprintln!( 84 | " * {remote}: {before} -> {after}", 85 | remote = remote_head.remote, 86 | before = remote_head.refname, 87 | after = fetched_remote_head.refname 88 | ); 89 | } 90 | eprintln!("You can sync them with these commands:"); 91 | for (remote_head, _) in &out_of_sync { 92 | eprintln!( 93 | " > git remote set-head {remote} --auto", 94 | remote = remote_head.remote, 95 | ); 96 | } 97 | eprintln!( 98 | r#"Or you can set base branches manually: 99 | * `git config trim.bases develop,master` will set base branches for git-trim for a repository. 100 | * `git config --global trim.bases develop,master` will set base branches for `git-trim` globally. 101 | * `git trim --bases develop,master` will temporarily set base branches for `git-trim`"# 102 | ); 103 | 104 | Ok(()) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/simple_glob.rs: -------------------------------------------------------------------------------- 1 | use std::iter::Iterator; 2 | 3 | use anyhow::{Context, Result}; 4 | use git2::{Direction, Remote}; 5 | use log::*; 6 | 7 | #[derive(Copy, Clone, Eq, PartialEq)] 8 | pub enum ExpansionSide { 9 | Right, 10 | Left, 11 | } 12 | 13 | pub fn expand_refspec( 14 | remote: &Remote, 15 | reference: &str, 16 | direction: Direction, 17 | side: ExpansionSide, 18 | ) -> Result> { 19 | for refspec in remote.refspecs() { 20 | let left = refspec.src().context("non-utf8 refspec src")?; 21 | let right = refspec.dst().context("non-utf8 refspec dst")?; 22 | if matches!( 23 | (direction, refspec.direction()), 24 | (Direction::Fetch, Direction::Push) | (Direction::Push, Direction::Fetch) 25 | ) { 26 | continue; 27 | } 28 | match side { 29 | ExpansionSide::Right => return Ok(expand(left, right, reference)), 30 | ExpansionSide::Left => return Ok(expand(right, left, reference)), 31 | }; 32 | } 33 | Ok(None) 34 | } 35 | 36 | fn expand(src: &str, dest: &str, reference: &str) -> Option { 37 | let src_stars = src.chars().filter(|&c| c == '*').count(); 38 | let dst_stars = dest.chars().filter(|&c| c == '*').count(); 39 | assert!( 40 | src_stars <= 1 && src_stars == dst_stars, 41 | "Unsupported refspec patterns: {}:{}", 42 | src, 43 | dest 44 | ); 45 | 46 | simple_match(src, reference).map(|matched| dest.replace('*', matched)) 47 | } 48 | 49 | fn simple_match<'a>(pattern: &str, reference: &'a str) -> Option<&'a str> { 50 | let src_stars = pattern.chars().filter(|&c| c == '*').count(); 51 | if src_stars <= 1 { 52 | if let Some(star) = pattern.find('*') { 53 | let left = &pattern[..star]; 54 | let right = &pattern[star + 1..]; 55 | if reference.starts_with(left) && reference.ends_with(right) { 56 | let matched = &reference[left.len()..reference.len() - right.len()]; 57 | return Some(matched); 58 | } 59 | } else if pattern == reference { 60 | return Some(""); 61 | } 62 | return None; 63 | } else { 64 | warn!( 65 | "Unsupported refspec patterns, too many asterisks: {}", 66 | pattern 67 | ); 68 | } 69 | None 70 | } 71 | -------------------------------------------------------------------------------- /src/subprocess.rs: -------------------------------------------------------------------------------- 1 | use std::collections::{HashMap, HashSet}; 2 | use std::process::{Command, Stdio}; 3 | 4 | use anyhow::{Context, Result}; 5 | use git2::{Config, Reference, Repository}; 6 | use log::*; 7 | 8 | use crate::branch::{LocalBranch, RemoteBranch, RemoteTrackingBranch, RemoteTrackingBranchStatus}; 9 | 10 | fn git(repo: &Repository, args: &[&str], level: log::Level) -> Result<()> { 11 | let workdir = repo.workdir().context("Bare repository is not supported")?; 12 | let workdir = workdir.to_str().context("non utf-8 workdir")?; 13 | log!(level, "> git {}", args.join(" ")); 14 | 15 | let mut cd_args = vec!["-C", workdir]; 16 | cd_args.extend_from_slice(args); 17 | let exit_status = Command::new("git").args(cd_args).status()?; 18 | if !exit_status.success() { 19 | Err(std::io::Error::from_raw_os_error(exit_status.code().unwrap_or(-1)).into()) 20 | } else { 21 | Ok(()) 22 | } 23 | } 24 | 25 | fn git_output(repo: &Repository, args: &[&str], level: log::Level) -> Result { 26 | let workdir = repo.workdir().context("Bare repository is not supported")?; 27 | let workdir = workdir.to_str().context("non utf-8 workdir")?; 28 | log!(level, "> git {}", args.join(" ")); 29 | 30 | let mut cd_args = vec!["-C", workdir]; 31 | cd_args.extend_from_slice(args); 32 | let output = Command::new("git") 33 | .args(cd_args) 34 | .stdin(Stdio::null()) 35 | .stdout(Stdio::piped()) 36 | .output()?; 37 | if !output.status.success() { 38 | return Err(std::io::Error::from_raw_os_error(output.status.code().unwrap_or(-1)).into()); 39 | } 40 | 41 | let str = std::str::from_utf8(&output.stdout)?.trim(); 42 | for line in str.lines() { 43 | trace!("| {}", line); 44 | } 45 | Ok(str.to_string()) 46 | } 47 | 48 | pub fn remote_update(repo: &Repository, dry_run: bool) -> Result<()> { 49 | if !dry_run { 50 | git(repo, &["remote", "update", "--prune"], Level::Info) 51 | } else { 52 | info!("> git remote update --prune (dry-run)"); 53 | Ok(()) 54 | } 55 | } 56 | 57 | /// Get whether there any commits are not in the `base` from the `commit` 58 | /// `git rev-list --cherry-pick --right-only --no-merges -n1 ..` 59 | pub fn is_merged_by_rev_list(repo: &Repository, base: &str, commit: &str) -> Result { 60 | let range = format!("{}...{}", base, commit); 61 | // Is there any revs that are not applied to the base in the branch? 62 | let output = git_output( 63 | repo, 64 | &[ 65 | "rev-list", 66 | "--cherry-pick", 67 | "--right-only", 68 | "--no-merges", 69 | "-n1", 70 | &range, 71 | ], 72 | Level::Trace, 73 | )?; 74 | 75 | // empty output means there aren't any revs that are not applied to the base. 76 | Ok(output.is_empty()) 77 | } 78 | 79 | /// Get branches that are merged with merge commit. 80 | /// `git branch --format '%(refname)' --merged ` 81 | pub fn get_noff_merged_locals( 82 | repo: &Repository, 83 | config: &Config, 84 | bases: &[RemoteTrackingBranch], 85 | ) -> Result> { 86 | let mut result = HashSet::new(); 87 | for base in bases { 88 | let refnames = git_output( 89 | repo, 90 | &[ 91 | "branch", 92 | "--format", 93 | "%(refname)", 94 | "--merged", 95 | &base.refname, 96 | ], 97 | Level::Trace, 98 | )?; 99 | for refname in refnames.lines() { 100 | if !refnames.starts_with("refs/") { 101 | // Detached HEAD is printed as '(HEAD detached at 1234abc)' 102 | continue; 103 | } 104 | let branch = LocalBranch::new(refname); 105 | let upstream = branch.fetch_upstream(repo, config)?; 106 | if let RemoteTrackingBranchStatus::Exists(upstream) = upstream { 107 | if base == &upstream { 108 | continue; 109 | } 110 | } 111 | let reference = repo.find_reference(refname)?; 112 | if reference.symbolic_target().is_some() { 113 | continue; 114 | } 115 | result.insert(branch); 116 | } 117 | } 118 | Ok(result) 119 | } 120 | 121 | /// Get remote tracking branches that are merged with merge commit. 122 | /// `git branch --format '%(refname)' --remote --merged ` 123 | pub fn get_noff_merged_remotes( 124 | repo: &Repository, 125 | bases: &[RemoteTrackingBranch], 126 | ) -> Result> { 127 | let mut result = HashSet::new(); 128 | for base in bases { 129 | let refnames = git_output( 130 | repo, 131 | &[ 132 | "branch", 133 | "--format", 134 | "%(refname)", 135 | "--remote", 136 | "--merged", 137 | &base.refname, 138 | ], 139 | Level::Trace, 140 | )?; 141 | for refname in refnames.lines() { 142 | let branch = RemoteTrackingBranch::new(refname); 143 | if base == &branch { 144 | continue; 145 | } 146 | let reference = repo.find_reference(refname)?; 147 | if reference.symbolic_target().is_some() { 148 | continue; 149 | } 150 | result.insert(branch); 151 | } 152 | } 153 | Ok(result) 154 | } 155 | 156 | #[derive(Debug)] 157 | pub struct RemoteHead { 158 | pub remote: String, 159 | pub refname: String, 160 | pub commit: String, 161 | } 162 | 163 | pub fn ls_remote_heads(repo: &Repository, remote_name: &str) -> Result> { 164 | let mut result = Vec::new(); 165 | for line in git_output(repo, &["ls-remote", "--heads", remote_name], Level::Trace)?.lines() { 166 | let records = line.split_whitespace().collect::>(); 167 | let commit = records[0].to_string(); 168 | let refname = records[1].to_string(); 169 | result.push(RemoteHead { 170 | remote: remote_name.to_owned(), 171 | refname, 172 | commit, 173 | }); 174 | } 175 | Ok(result) 176 | } 177 | 178 | pub fn ls_remote_head(repo: &Repository, remote_name: &str) -> Result { 179 | let command = &["ls-remote", "--symref", remote_name, "HEAD"]; 180 | let lines = git_output(repo, command, Level::Trace)?; 181 | let mut refname = None; 182 | let mut commit = None; 183 | for line in lines.lines() { 184 | if line.starts_with("ref: ") { 185 | refname = Some( 186 | line["ref: ".len()..line.len() - "HEAD".len()] 187 | .trim() 188 | .to_owned(), 189 | ) 190 | } else { 191 | commit = line.split_whitespace().next().map(|x| x.to_owned()); 192 | } 193 | } 194 | if let (Some(refname), Some(commit)) = (refname, commit) { 195 | Ok(RemoteHead { 196 | remote: remote_name.to_owned(), 197 | refname, 198 | commit, 199 | }) 200 | } else { 201 | Err(anyhow::anyhow!("HEAD not found on {}", remote_name)) 202 | } 203 | } 204 | 205 | /// Get worktrees and its paths without HEAD 206 | pub fn get_worktrees(repo: &Repository) -> Result> { 207 | // TODO: `libgit2` has `git2_worktree_*` APIs. However it is not ported to `git2`. Use subprocess directly. 208 | let mut result = HashMap::new(); 209 | let mut worktree = None; 210 | let mut branch = None; 211 | for line in git_output(repo, &["worktree", "list", "--porcelain"], Level::Trace)?.lines() { 212 | if let Some(stripped) = line.strip_prefix("worktree ") { 213 | worktree = Some(stripped.to_owned()); 214 | } else if let Some(stripped) = line.strip_prefix("branch ") { 215 | branch = Some(LocalBranch::new(stripped)); 216 | } else if line.is_empty() { 217 | if let (Some(worktree), Some(branch)) = (worktree.take(), branch.take()) { 218 | result.insert(branch, worktree); 219 | } 220 | } 221 | } 222 | 223 | if let (Some(worktree), Some(branch)) = (worktree.take(), branch.take()) { 224 | result.insert(branch, worktree); 225 | } 226 | 227 | let head = repo.head()?; 228 | if head.is_branch() { 229 | let head_branch = LocalBranch::new(head.name().context("non-utf8 head branch name")?); 230 | result.remove(&head_branch); 231 | } 232 | Ok(result) 233 | } 234 | 235 | pub fn checkout(repo: &Repository, head: Reference, dry_run: bool) -> Result<()> { 236 | let head_refname = head.name().context("non-utf8 head ref name")?; 237 | if !dry_run { 238 | git(repo, &["checkout", head_refname], Level::Info) 239 | } else { 240 | info!("> git checkout {} (dry-run)", head_refname); 241 | 242 | println!("Note: switching to '{}' (dry run)", head_refname); 243 | println!("You are in 'detached HED' state... blah blah..."); 244 | let commit = head.peel_to_commit()?; 245 | let message = commit.message().context("non-utf8 head ref name")?; 246 | println!( 247 | "HEAD is now at {} {} (dry run)", 248 | &commit.id().to_string()[..7], 249 | message.lines().next().unwrap_or_default() 250 | ); 251 | Ok(()) 252 | } 253 | } 254 | 255 | pub fn branch_delete(repo: &Repository, branches: &[&LocalBranch], dry_run: bool) -> Result<()> { 256 | let mut args = vec!["branch", "--delete", "--force"]; 257 | let mut branch_names = Vec::new(); 258 | for branch in branches { 259 | let reference = repo.find_reference(&branch.refname)?; 260 | assert!(reference.is_branch()); 261 | let branch_name = reference.shorthand().context("non utf-8 branch name")?; 262 | branch_names.push(branch_name.to_owned()); 263 | } 264 | args.extend(branch_names.iter().map(|x| x.as_str())); 265 | 266 | if !dry_run { 267 | git(repo, &args, Level::Info) 268 | } else { 269 | info!("> git {} (dry-run)", args.join(" ")); 270 | for branch_name in branch_names { 271 | println!("Delete branch {} (dry run).", branch_name); 272 | } 273 | Ok(()) 274 | } 275 | } 276 | 277 | pub fn push_delete( 278 | repo: &Repository, 279 | remote_name: &str, 280 | remote_branches: &[&RemoteBranch], 281 | dry_run: bool, 282 | ) -> Result<()> { 283 | assert!(remote_branches 284 | .iter() 285 | .all(|branch| branch.remote == remote_name)); 286 | let mut command = vec!["push", "--delete", "--no-verify"]; 287 | if dry_run { 288 | command.push("--dry-run"); 289 | } 290 | command.push(remote_name); 291 | for remote_branch in remote_branches { 292 | command.push(&remote_branch.refname); 293 | } 294 | git(repo, &command, Level::Trace) 295 | } 296 | -------------------------------------------------------------------------------- /src/util.rs: -------------------------------------------------------------------------------- 1 | use std::ops::Deref; 2 | 3 | /// Use with caution. 4 | /// It makes wrapping type T to be Send + Sync. 5 | /// Make sure T is semantically Send + Sync 6 | #[derive(Copy, Clone)] 7 | pub struct ForceSendSync(T); 8 | 9 | unsafe impl Sync for ForceSendSync {} 10 | unsafe impl Send for ForceSendSync {} 11 | 12 | impl ForceSendSync { 13 | pub fn new(value: T) -> Self { 14 | Self(value) 15 | } 16 | pub fn unwrap(self) -> T { 17 | self.0 18 | } 19 | } 20 | 21 | impl Deref for ForceSendSync { 22 | type Target = T; 23 | 24 | fn deref(&self) -> &Self::Target { 25 | &self.0 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tests/config.rs: -------------------------------------------------------------------------------- 1 | mod fixture; 2 | 3 | use std::collections::HashSet; 4 | use std::convert::TryFrom; 5 | use std::iter::FromIterator; 6 | 7 | use anyhow::Result; 8 | use git2::Repository; 9 | 10 | use git_trim::args::{Args, DeleteFilter, DeleteUnit, Scope}; 11 | use git_trim::config::{Config, ConfigValue}; 12 | use git_trim::Git; 13 | 14 | use fixture::{rc, Fixture}; 15 | 16 | fn fixture() -> Fixture { 17 | rc().append_fixture_trace( 18 | r#" 19 | git init origin 20 | origin < README.md 24 | git add README.md 25 | git commit -m "Initial commit" 26 | EOF 27 | 28 | git clone origin local 29 | "#, 30 | ) 31 | } 32 | 33 | #[test] 34 | fn test_bases_implicit_value() -> Result<()> { 35 | let guard = fixture().prepare( 36 | "local", 37 | r#" 38 | local < Result<()> { 55 | let guard = fixture().prepare( 56 | "local", 57 | r#" 58 | local < Result<()> { 76 | let guard = fixture().prepare( 77 | "local", 78 | r#" 79 | local < Result<()> { 106 | let guard = fixture().prepare( 107 | "local", 108 | r#" 109 | local < Result<()> { 133 | let guard = fixture().prepare( 134 | "local", 135 | r#" 136 | local < Result<()> { 160 | let guard = fixture().prepare( 161 | "local", 162 | r#" 163 | local < Fixture { 17 | rc().append_fixture_trace( 18 | r#" 19 | git init origin --bare 20 | 21 | git clone origin local 22 | local < README.md 29 | git add README.md 30 | git commit -m "Initial commit" 31 | git push -u origin master 32 | EOF 33 | 34 | git clone origin contributer 35 | within contributer < PlanParam<'static> { 61 | PlanParam { 62 | delete: DeleteFilter::from_iter(vec![ 63 | DeleteRange::MergedLocal, 64 | DeleteRange::MergedRemote(Scope::Scoped("origin".to_string())), 65 | ]), 66 | ..test_default_param() 67 | } 68 | } 69 | 70 | #[test] 71 | fn test_default_config_tries_to_delete_accidential_track() -> Result<()> { 72 | let guard = fixture().prepare( 73 | "local", 74 | r#" 75 | local < Result<()> { 109 | let guard = fixture().prepare( 110 | "local", 111 | r#" 112 | local < Fixture { 22 | Fixture::default() 23 | } 24 | 25 | fn append_fixture(&self, log_level: &str, appended: &str) -> Fixture { 26 | let mut fixture = String::new(); 27 | writeln!(fixture, "{}", self.fixture).unwrap(); 28 | writeln!(fixture, "echo ::set-level::{} >&2", log_level).unwrap(); 29 | writeln!(fixture, "{}", textwrap::dedent(appended)).unwrap(); 30 | Fixture { 31 | fixture, 32 | epilogue: self.epilogue.clone(), 33 | } 34 | } 35 | 36 | pub fn append_fixture_none(&self, appended: &str) -> Fixture { 37 | self.append_fixture("none", appended) 38 | } 39 | 40 | pub fn append_fixture_trace(&self, appended: &str) -> Fixture { 41 | self.append_fixture("trace", appended) 42 | } 43 | 44 | pub fn append_fixture_debug(&self, appended: &str) -> Fixture { 45 | self.append_fixture("debug", appended) 46 | } 47 | 48 | fn append_epilogue(&self, appended: &str) -> Fixture { 49 | let mut epilogue = String::new(); 50 | writeln!(epilogue, "{}", self.epilogue).unwrap(); 51 | writeln!(epilogue, "{}", textwrap::dedent(appended)).unwrap(); 52 | Fixture { 53 | fixture: self.fixture.clone(), 54 | epilogue, 55 | } 56 | } 57 | 58 | pub fn prepare( 59 | &self, 60 | working_directory: &str, 61 | last_fixture: &str, 62 | ) -> std::io::Result { 63 | let _ = env_logger::builder().is_test(true).try_init(); 64 | 65 | let tempdir = tempdir()?; 66 | println!("{:?}", tempdir.path()); 67 | let mut command = Command::new("bash"); 68 | command 69 | .args(&["--noprofile", "--norc", "-xeo", "pipefail"]) 70 | .current_dir(tempdir.path()) 71 | .stdin(Stdio::piped()) 72 | .stdout(Stdio::piped()) 73 | .stderr(Stdio::piped()); 74 | if !cfg!(windows) { 75 | command.env_clear(); 76 | } else { 77 | // If I don't touch any env, Rust just calls `CreateProcessW` with "bash" 78 | // However, Windows finds the binary from "C:\windows\system32" first [1] 79 | // and "bash.exe" is there if WSL is installed to the System. 80 | // However, when there is no WSL distro (ex: GitHub Actions), it just raise an error. 81 | // When I touch any of env, Rust finds the binary from `%PATH%` [2] 82 | // It is weird and unreliable hack, but I DONT WANT WSL BASH AND IT WORKS FOR NOW. 83 | // [1] https://docs.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw 84 | // [2] https://github.com/rust-lang/rust/issues/37519 85 | command.env("ASDF", "QWER"); 86 | } 87 | let mut bash = command.spawn()?; 88 | 89 | let mut stdin = bash.stdin.take().unwrap(); 90 | let merged_fixture = self 91 | .append_fixture_debug(&textwrap::dedent(last_fixture)) 92 | .append_fixture_debug(&self.epilogue); 93 | writeln!(stdin, "{}", &merged_fixture.fixture).unwrap(); 94 | drop(stdin); 95 | 96 | let stdout_thread = spawn({ 97 | let stdout = bash.stdout.take().unwrap(); 98 | move || { 99 | for line in BufReader::new(stdout).lines() { 100 | match line { 101 | Ok(line) if line.starts_with('+') => trace!("{}", line), 102 | Ok(line) => info!("{}", line), 103 | Err(err) => error!("{}", err), 104 | } 105 | } 106 | } 107 | }); 108 | 109 | let stderr_thread = spawn({ 110 | let stderr = bash.stderr.take().unwrap(); 111 | move || { 112 | let mut level = Some(Level::Debug); 113 | for line in BufReader::new(stderr).lines() { 114 | match line { 115 | Ok(line) if line.starts_with('+') && level.is_none() => {} 116 | Ok(line) if line.starts_with('+') => { 117 | log!(target: "stderr", level.unwrap(), "{}", line) 118 | } 119 | Ok(line) if line.starts_with("::set-level::") => { 120 | if line.starts_with("::set-level::none") { 121 | level = None 122 | } else if line.starts_with("::set-level::trace") { 123 | level = Some(Level::Trace) 124 | } else if line.starts_with("::set-level::debug") { 125 | level = Some(Level::Debug) 126 | } 127 | if let Some(level) = level { 128 | log!(target: "stderr-set-level", level, "{}", line); 129 | } 130 | } 131 | Ok(line) => info!(target: "stderr", "stderr: {}", line), 132 | Err(err) => error!(target: "stderr", "stderr: {}", err), 133 | } 134 | } 135 | } 136 | }); 137 | stdout_thread.join().unwrap(); 138 | stderr_thread.join().unwrap(); 139 | 140 | let exit_status = bash.wait()?; 141 | if !exit_status.success() { 142 | return Err(Error::from_raw_os_error(exit_status.code().unwrap_or(-1))); 143 | } 144 | 145 | Ok(FixtureGuard { 146 | tempdir, 147 | working_directory: working_directory.to_string(), 148 | }) 149 | } 150 | } 151 | 152 | #[must_use] 153 | pub struct FixtureGuard { 154 | tempdir: TempDir, 155 | working_directory: String, 156 | } 157 | 158 | impl FixtureGuard { 159 | pub fn working_directory(&self) -> PathBuf { 160 | self.tempdir.path().join(&self.working_directory) 161 | } 162 | } 163 | 164 | pub fn rc() -> Fixture { 165 | Fixture::new() 166 | .append_fixture_none( 167 | r#" 168 | shopt -s expand_aliases 169 | within() { 170 | pushd $1 > /dev/null 171 | source /dev/stdin 172 | popd > /dev/null 173 | } 174 | alias upstream='within upstream' 175 | alias origin='within origin' 176 | alias local='within local' 177 | 178 | # TODO: Temporarily fix to pass tests on macOS 179 | git() { 180 | command git -c init.defaultBranch=master "$@" 181 | } 182 | "#, 183 | ) 184 | .append_epilogue( 185 | r#" 186 | local < ({ 199 | use ::std::collections::HashSet; 200 | use ::std::iter::FromIterator; 201 | 202 | HashSet::from_iter(vec![$(From::from($x),)*]) 203 | }); 204 | {$($x:expr,)*} => ($crate::set!{$($x),*}) 205 | } 206 | 207 | #[allow(unused)] 208 | pub fn test_default_param() -> PlanParam<'static> { 209 | use DeleteRange::*; 210 | PlanParam { 211 | bases: vec!["master"], 212 | protected_patterns: Vec::new(), 213 | delete: DeleteFilter::from_iter(vec![ 214 | MergedLocal, 215 | MergedRemote(Scope::All), 216 | Stray, 217 | Diverged(Scope::All), 218 | ]), 219 | detach: true, 220 | } 221 | } 222 | 223 | #[test] 224 | #[ignore] 225 | fn test() -> std::io::Result<()> { 226 | let _guard = Fixture::new() 227 | .append_epilogue("echo 'epilogue'") 228 | .append_fixture("debug", "echo 'fixture'") 229 | .prepare("", ""); 230 | Ok(()) 231 | } 232 | -------------------------------------------------------------------------------- /tests/hub_cli_checkout.rs: -------------------------------------------------------------------------------- 1 | mod fixture; 2 | 3 | use std::convert::TryFrom; 4 | 5 | use anyhow::Result; 6 | use git2::Repository; 7 | 8 | use git_trim::{get_trim_plan, ClassifiedBranch, Git, LocalBranch, RemoteBranch}; 9 | 10 | use fixture::{rc, test_default_param, Fixture}; 11 | 12 | fn fixture() -> Fixture { 13 | rc().append_fixture_trace( 14 | r#" 15 | git init upstream 16 | upstream < README.md 20 | git add README.md 21 | git commit -m "Initial commit" 22 | EOF 23 | git clone upstream origin -o upstream 24 | origin < Result<()> { 52 | let guard = fixture().prepare( 53 | "local", 54 | r#" 55 | local < Result<()> { 71 | let guard = fixture().prepare( 72 | "local", 73 | r#" 74 | local < Result<()> { 103 | let guard = fixture().prepare( 104 | "local", 105 | r#" 106 | local < Result<()> { 136 | let guard = fixture().prepare( 137 | "local", 138 | r#" 139 | local < Result<()> { 174 | let guard = fixture().prepare( 175 | "local", 176 | r#" 177 | local < Result<()> { 213 | let guard = fixture().prepare( 214 | "local", 215 | r#" 216 | local < Result<()> { 242 | let guard = fixture().prepare( 243 | "local", 244 | r#" 245 | local < Fixture { 13 | rc().append_fixture_trace( 14 | r#" 15 | git init origin 16 | origin < README.md 20 | git add README.md 21 | git commit -m "Initial commit" 22 | EOF 23 | git clone origin local 24 | local < Result<()> { 47 | let guard = fixture().prepare( 48 | "local", 49 | r#" 50 | origin < Result<()> { 71 | let guard = fixture().prepare( 72 | "local", 73 | r#" 74 | origin < Result<()> { 97 | let guard = fixture().prepare( 98 | "local", 99 | r#" 100 | origin < Result<()> { 121 | let fixture = rc().append_fixture_trace( 122 | r#" 123 | git init origin 124 | origin < README.md 128 | git add README.md 129 | git commit -m "Initial commit" 130 | EOF 131 | git clone origin local 132 | local < Fixture { 17 | rc().append_fixture_trace( 18 | r#" 19 | git init origin 20 | origin < README.md 24 | git add README.md 25 | git commit -m "Initial commit" 26 | EOF 27 | git clone origin local 28 | local < PlanParam<'static> { 47 | PlanParam { 48 | delete: DeleteFilter::from_iter(vec![ 49 | DeleteRange::MergedLocal, 50 | DeleteRange::MergedRemote(Scope::Scoped("origin".to_owned())), 51 | DeleteRange::Stray, 52 | DeleteRange::Diverged(Scope::Scoped("origin".to_owned())), 53 | DeleteRange::Local, 54 | DeleteRange::Remote(Scope::Scoped("origin".to_owned())), 55 | ]), 56 | ..test_default_param() 57 | } 58 | } 59 | 60 | #[test] 61 | fn test_merged_non_tracking() -> Result<()> { 62 | let guard = fixture().prepare( 63 | "local", 64 | r#" 65 | origin < Result<()> { 86 | let guard = fixture().prepare( 87 | "local", 88 | r#" 89 | origin < Fixture { 16 | rc().append_fixture_trace( 17 | r#" 18 | git init origin 19 | origin < README.md 23 | git add README.md 24 | git commit -m "Initial commit" 25 | EOF 26 | 27 | git clone origin local 28 | local < Result<()> { 38 | let guard = fixture().prepare( 39 | "local", 40 | r#" 41 | local < Fixture { 15 | rc().append_fixture_trace( 16 | r#" 17 | git init origin 18 | origin < README.md 22 | git add README.md 23 | git commit -m "Initial commit" 24 | 25 | git branch develop master 26 | EOF 27 | git clone origin local 28 | local < PlanParam<'static> { 41 | PlanParam { 42 | bases: vec!["develop", "master"], // Need to set bases manually for git flow 43 | ..test_default_param() 44 | } 45 | } 46 | 47 | #[test] 48 | fn test_feature_to_develop() -> Result<()> { 49 | let guard = fixture().prepare( 50 | "local", 51 | r#" 52 | local < Result<()> { 82 | let guard = fixture().prepare( 83 | "local", 84 | r#" 85 | local < Result<()> { 115 | let guard = fixture().prepare( 116 | "local", 117 | r#" 118 | local < Result<()> { 151 | let guard = fixture().prepare( 152 | "local", 153 | r#" 154 | local < Result<()> { 187 | let guard = fixture().prepare( 188 | "local", 189 | r#" 190 | # prepare awesome patch 191 | local < Result<()> { 222 | let guard = fixture().prepare( 223 | "local", 224 | r#" 225 | # prepare awesome patch 226 | local < Result<()> { 257 | let guard = fixture().prepare( 258 | "local", 259 | r#" 260 | local < Result<()> { 288 | let guard = fixture().prepare( 289 | "local", 290 | r#" 291 | # prepare awesome patch 292 | local < Result<()> { 321 | let guard = fixture().prepare( 322 | "local", 323 | r#" 324 | local < Result<()> { 355 | let guard = fixture().prepare( 356 | "local", 357 | r#" 358 | local < Result<()> { 392 | let guard = fixture().prepare( 393 | "local", 394 | r#" 395 | local < Result<()> { 424 | let guard = fixture().prepare( 425 | "local", 426 | r#" 427 | origin < Fixture { 13 | rc().append_fixture_trace( 14 | r#" 15 | git init origin 16 | origin < README.md 20 | git add README.md 21 | git commit -m "Initial commit" 22 | EOF 23 | git clone origin local 24 | local < Result<()> { 44 | let guard = fixture().prepare( 45 | "local", 46 | r#" 47 | origin < Result<()> { 68 | let guard = fixture().prepare( 69 | "local", 70 | r#" 71 | origin < Result<()> { 96 | let guard = fixture().prepare( 97 | "local", 98 | r#" 99 | origin < Result<()> { 119 | let guard = fixture().prepare( 120 | "local", 121 | r#" 122 | origin < Result<()> { 147 | let guard = fixture().prepare( 148 | "local", 149 | r#" 150 | origin < Result<()> { 168 | let guard = fixture().prepare( 169 | "local", 170 | r#" 171 | origin < Result<()> { 194 | let guard = fixture().prepare("local", r#""#)?; 195 | let git = Git::try_from(Repository::open(guard.working_directory())?)?; 196 | let plan = get_trim_plan(&git, &test_default_param())?; 197 | assert_eq!(plan.to_delete, set! {}); 198 | Ok(()) 199 | } 200 | 201 | #[test] 202 | fn test_rejected_but_forgot_to_delete_and_edited() -> Result<()> { 203 | let guard = fixture().prepare( 204 | "local", 205 | r#" 206 | local < Fixture { 15 | rc().append_fixture_trace( 16 | r#" 17 | git init upstream 18 | upstream < README.md 22 | git add README.md 23 | git commit -m "Initial commit" 24 | git branch develop master 25 | EOF 26 | git clone upstream origin -o upstream 27 | origin < PlanParam<'static> { 52 | PlanParam { 53 | bases: vec!["develop", "master"], // Need to set bases manually for git flow 54 | ..test_default_param() 55 | } 56 | } 57 | 58 | #[test] 59 | fn test_feature_to_develop() -> Result<()> { 60 | let guard = fixture().prepare( 61 | "local", 62 | r#" 63 | local < Result<()> { 102 | let guard = fixture().prepare( 103 | "local", 104 | r#" 105 | local < Result<()> { 140 | let guard = fixture().prepare( 141 | "local", 142 | r#" 143 | local < Result<()> { 185 | let guard = fixture().prepare( 186 | "local", 187 | r#" 188 | local < Result<()> { 226 | let guard = fixture().prepare( 227 | "local", 228 | r#" 229 | # prepare awesome patch 230 | local < Result<()> { 270 | let guard = fixture().prepare( 271 | "local", 272 | r#" 273 | # prepare awesome patch 274 | local < Result<()> { 310 | let guard = fixture().prepare( 311 | "local", 312 | r#" 313 | local < Result<()> { 347 | let guard = fixture().prepare( 348 | "local", 349 | r#" 350 | # prepare awesome patch 351 | local < Result<()> { 386 | let guard = fixture().prepare( 387 | "local", 388 | r#" 389 | local < Result<()> { 429 | let guard = fixture().prepare( 430 | "local", 431 | r#" 432 | local < Result<()> { 475 | let guard = fixture().prepare( 476 | "local", 477 | r#" 478 | local < Fixture { 13 | rc().append_fixture_trace( 14 | r#" 15 | git init upstream 16 | upstream < README.md 20 | git add README.md 21 | git commit -m "Initial commit" 22 | EOF 23 | git clone upstream origin -o upstream 24 | origin < Result<()> { 53 | let guard = fixture().prepare( 54 | "local", 55 | r#" 56 | origin < Result<()> { 82 | let guard = fixture().prepare( 83 | "local", 84 | r#" 85 | origin < Result<()> { 116 | let guard = fixture().prepare( 117 | "local", 118 | r#" 119 | origin < Result<()> { 142 | let guard = fixture().prepare( 143 | "local", 144 | r#" 145 | origin < Result<()> { 173 | let guard = fixture().prepare( 174 | "local", 175 | r#" 176 | origin < Result<()> { 196 | let guard = fixture().prepare( 197 | "local", 198 | r#" 199 | origin < Result<()> { 224 | let guard = fixture().prepare( 225 | "local", 226 | r#" 227 | origin < Result<()> { 241 | let guard = fixture().prepare( 242 | "local", 243 | r#" 244 | origin < Fixture { 13 | rc().append_fixture_trace( 14 | r#" 15 | git init origin 16 | origin < README.md 20 | git add README.md 21 | git commit -m "Initial commit" 22 | EOF 23 | 24 | git clone origin local 25 | local <> README.md 34 | git add README.md 35 | git commit -m "Yay" 36 | git push -u origin worktree 37 | EOF 38 | 39 | origin < Result<()> { 49 | let guard = fixture().prepare("local", r#""#)?; 50 | 51 | let git = Git::try_from(Repository::open(guard.working_directory())?)?; 52 | let plan = get_trim_plan(&git, &test_default_param())?; 53 | 54 | assert!(plan.preserved.iter().any(|w| { 55 | w.branch == ClassifiedBranch::MergedLocal(LocalBranch::new("refs/heads/worktree")) 56 | && w.reason.contains("worktree") 57 | })); 58 | Ok(()) 59 | } 60 | --------------------------------------------------------------------------------