├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── examples └── basic.rs └── src ├── lib.rs └── main.rs /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | build: 7 | name: ${{ matrix.os }} (${{ matrix.rust }}) 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | matrix: 11 | rust: 12 | - 1.74.0 13 | - stable 14 | # https://github.com/actions/virtual-environments#available-images 15 | os: 16 | - macOS-13 # x86 17 | - macOS-14 # arm 18 | - ubuntu-20.04 19 | - ubuntu-22.04 20 | - ubuntu-24.04 21 | - windows-2019 22 | - windows-2022 23 | steps: 24 | 25 | - uses: actions/checkout@v3 26 | 27 | - name: Install ${{ matrix.rust }} toolchain 28 | uses: dtolnay/rust-toolchain@master 29 | with: 30 | toolchain: ${{ matrix.rust }} 31 | 32 | - name: Build (with no features) 33 | run: cargo build --no-default-features 34 | 35 | - name: Build 36 | run: cargo build 37 | 38 | - name: Build (with "json" feature) 39 | run: cargo build --features json 40 | 41 | # https://doc.rust-lang.org/nightly/cargo/guide/continuous-integration.html#verifying-latest-dependencies 42 | latest_deps: 43 | name: Latest Dependencies 44 | runs-on: ubuntu-latest 45 | continue-on-error: true 46 | env: 47 | CARGO_RESOLVER_INCOMPATIBLE_RUST_VERSIONS: allow 48 | steps: 49 | - uses: actions/checkout@v4 50 | - run: rustup update stable && rustup default stable 51 | - run: cargo update --verbose 52 | - run: cargo build --verbose 53 | - run: cargo build --verbose --features json 54 | - run: cargo build --verbose --no-default-features 55 | 56 | security-audit: 57 | runs-on: ubuntu-latest 58 | steps: 59 | - uses: actions/checkout@v3 60 | - uses: rustsec/audit-check@v1.4.1 61 | with: 62 | token: ${{ secrets.GITHUB_TOKEN }} 63 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # Copyright 2022-2023, axodotdev 2 | # SPDX-License-Identifier: MIT or Apache-2.0 3 | # 4 | # CI that: 5 | # 6 | # * checks for a Git Tag that looks like a release 7 | # * builds artifacts with cargo-dist (archives, installers, hashes) 8 | # * uploads those artifacts to temporary workflow zip 9 | # * on success, uploads the artifacts to a Github Release 10 | # 11 | # Note that the Github Release will be created with a generated 12 | # title/body based on your changelogs. 13 | 14 | name: Release 15 | 16 | permissions: 17 | contents: write 18 | 19 | # This task will run whenever you push a git tag that looks like a version 20 | # like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc. 21 | # Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where 22 | # PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION 23 | # must be a Cargo-style SemVer Version (must have at least major.minor.patch). 24 | # 25 | # If PACKAGE_NAME is specified, then the announcement will be for that 26 | # package (erroring out if it doesn't have the given version or isn't cargo-dist-able). 27 | # 28 | # If PACKAGE_NAME isn't specified, then the announcement will be for all 29 | # (cargo-dist-able) packages in the workspace with that version (this mode is 30 | # intended for workspaces with only one dist-able package, or with all dist-able 31 | # packages versioned/released in lockstep). 32 | # 33 | # If you push multiple tags at once, separate instances of this workflow will 34 | # spin up, creating an independent announcement for each one. However Github 35 | # will hard limit this to 3 tags per commit, as it will assume more tags is a 36 | # mistake. 37 | # 38 | # If there's a prerelease-style suffix to the version, then the release(s) 39 | # will be marked as a prerelease. 40 | on: 41 | push: 42 | tags: 43 | - '**[0-9]+.[0-9]+.[0-9]+*' 44 | pull_request: 45 | 46 | jobs: 47 | # Run 'cargo dist plan' (or host) to determine what tasks we need to do 48 | plan: 49 | runs-on: ubuntu-latest 50 | outputs: 51 | val: ${{ steps.plan.outputs.manifest }} 52 | tag: ${{ !github.event.pull_request && github.ref_name || '' }} 53 | tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} 54 | publishing: ${{ !github.event.pull_request }} 55 | env: 56 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 57 | steps: 58 | - uses: actions/checkout@v4 59 | with: 60 | submodules: recursive 61 | - name: Install cargo-dist 62 | # we specify bash to get pipefail; it guards against the `curl` command 63 | # failing. otherwise `sh` won't catch that `curl` returned non-0 64 | shell: bash 65 | run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.12.0/cargo-dist-installer.sh | sh" 66 | # sure would be cool if github gave us proper conditionals... 67 | # so here's a doubly-nested ternary-via-truthiness to try to provide the best possible 68 | # functionality based on whether this is a pull_request, and whether it's from a fork. 69 | # (PRs run on the *source* but secrets are usually on the *target* -- that's *good* 70 | # but also really annoying to build CI around when it needs secrets to work right.) 71 | - id: plan 72 | run: | 73 | cargo dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json 74 | echo "cargo dist ran successfully" 75 | cat plan-dist-manifest.json 76 | echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT" 77 | - name: "Upload dist-manifest.json" 78 | uses: actions/upload-artifact@v4 79 | with: 80 | name: artifacts-plan-dist-manifest 81 | path: plan-dist-manifest.json 82 | 83 | # Build and packages all the platform-specific things 84 | build-local-artifacts: 85 | name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) 86 | # Let the initial task tell us to not run (currently very blunt) 87 | needs: 88 | - plan 89 | if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} 90 | strategy: 91 | fail-fast: false 92 | # Target platforms/runners are computed by cargo-dist in create-release. 93 | # Each member of the matrix has the following arguments: 94 | # 95 | # - runner: the github runner 96 | # - dist-args: cli flags to pass to cargo dist 97 | # - install-dist: expression to run to install cargo-dist on the runner 98 | # 99 | # Typically there will be: 100 | # - 1 "global" task that builds universal installers 101 | # - N "local" tasks that build each platform's binaries and platform-specific installers 102 | matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }} 103 | runs-on: ${{ matrix.runner }} 104 | env: 105 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 106 | BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json 107 | steps: 108 | - uses: actions/checkout@v4 109 | with: 110 | submodules: recursive 111 | - uses: swatinem/rust-cache@v2 112 | - name: Install cargo-dist 113 | run: ${{ matrix.install_dist }} 114 | # Get the dist-manifest 115 | - name: Fetch local artifacts 116 | uses: actions/download-artifact@v4 117 | with: 118 | pattern: artifacts-* 119 | path: target/distrib/ 120 | merge-multiple: true 121 | - name: Install dependencies 122 | run: | 123 | ${{ matrix.packages_install }} 124 | - name: Build artifacts 125 | run: | 126 | # Actually do builds and make zips and whatnot 127 | cargo dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json 128 | echo "cargo dist ran successfully" 129 | - id: cargo-dist 130 | name: Post-build 131 | # We force bash here just because github makes it really hard to get values up 132 | # to "real" actions without writing to env-vars, and writing to env-vars has 133 | # inconsistent syntax between shell and powershell. 134 | shell: bash 135 | run: | 136 | # Parse out what we just built and upload it to scratch storage 137 | echo "paths<> "$GITHUB_OUTPUT" 138 | jq --raw-output ".artifacts[]?.path | select( . != null )" dist-manifest.json >> "$GITHUB_OUTPUT" 139 | echo "EOF" >> "$GITHUB_OUTPUT" 140 | 141 | cp dist-manifest.json "$BUILD_MANIFEST_NAME" 142 | - name: "Upload artifacts" 143 | uses: actions/upload-artifact@v4 144 | with: 145 | name: artifacts-build-local-${{ join(matrix.targets, '_') }} 146 | path: | 147 | ${{ steps.cargo-dist.outputs.paths }} 148 | ${{ env.BUILD_MANIFEST_NAME }} 149 | 150 | # Build and package all the platform-agnostic(ish) things 151 | build-global-artifacts: 152 | needs: 153 | - plan 154 | - build-local-artifacts 155 | runs-on: "ubuntu-20.04" 156 | env: 157 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 158 | BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json 159 | steps: 160 | - uses: actions/checkout@v4 161 | with: 162 | submodules: recursive 163 | - name: Install cargo-dist 164 | shell: bash 165 | run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.12.0/cargo-dist-installer.sh | sh" 166 | # Get all the local artifacts for the global tasks to use (for e.g. checksums) 167 | - name: Fetch local artifacts 168 | uses: actions/download-artifact@v4 169 | with: 170 | pattern: artifacts-* 171 | path: target/distrib/ 172 | merge-multiple: true 173 | - id: cargo-dist 174 | shell: bash 175 | run: | 176 | cargo dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json 177 | echo "cargo dist ran successfully" 178 | 179 | # Parse out what we just built and upload it to scratch storage 180 | echo "paths<> "$GITHUB_OUTPUT" 181 | jq --raw-output ".artifacts[]?.path | select( . != null )" dist-manifest.json >> "$GITHUB_OUTPUT" 182 | echo "EOF" >> "$GITHUB_OUTPUT" 183 | 184 | cp dist-manifest.json "$BUILD_MANIFEST_NAME" 185 | - name: "Upload artifacts" 186 | uses: actions/upload-artifact@v4 187 | with: 188 | name: artifacts-build-global 189 | path: | 190 | ${{ steps.cargo-dist.outputs.paths }} 191 | ${{ env.BUILD_MANIFEST_NAME }} 192 | # Determines if we should publish/announce 193 | host: 194 | needs: 195 | - plan 196 | - build-local-artifacts 197 | - build-global-artifacts 198 | # Only run if we're "publishing", and only if local and global didn't fail (skipped is fine) 199 | if: ${{ always() && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }} 200 | env: 201 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 202 | runs-on: "ubuntu-20.04" 203 | outputs: 204 | val: ${{ steps.host.outputs.manifest }} 205 | steps: 206 | - uses: actions/checkout@v4 207 | with: 208 | submodules: recursive 209 | - name: Install cargo-dist 210 | run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.12.0/cargo-dist-installer.sh | sh" 211 | # Fetch artifacts from scratch-storage 212 | - name: Fetch artifacts 213 | uses: actions/download-artifact@v4 214 | with: 215 | pattern: artifacts-* 216 | path: target/distrib/ 217 | merge-multiple: true 218 | # This is a harmless no-op for Github Releases, hosting for that happens in "announce" 219 | - id: host 220 | shell: bash 221 | run: | 222 | cargo dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json 223 | echo "artifacts uploaded and released successfully" 224 | cat dist-manifest.json 225 | echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT" 226 | - name: "Upload dist-manifest.json" 227 | uses: actions/upload-artifact@v4 228 | with: 229 | # Overwrite the previous copy 230 | name: artifacts-dist-manifest 231 | path: dist-manifest.json 232 | 233 | # Create a Github Release while uploading all files to it 234 | announce: 235 | needs: 236 | - plan 237 | - host 238 | # use "always() && ..." to allow us to wait for all publish jobs while 239 | # still allowing individual publish jobs to skip themselves (for prereleases). 240 | # "host" however must run to completion, no skipping allowed! 241 | if: ${{ always() && needs.host.result == 'success' }} 242 | runs-on: "ubuntu-20.04" 243 | env: 244 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 245 | steps: 246 | - uses: actions/checkout@v4 247 | with: 248 | submodules: recursive 249 | - name: "Download Github Artifacts" 250 | uses: actions/download-artifact@v4 251 | with: 252 | pattern: artifacts-* 253 | path: artifacts 254 | merge-multiple: true 255 | - name: Cleanup 256 | run: | 257 | # Remove the granular manifests 258 | rm -f artifacts/*-dist-manifest.json 259 | - name: Create Github Release 260 | uses: ncipollo/release-action@v1 261 | with: 262 | tag: ${{ needs.plan.outputs.tag }} 263 | name: ${{ fromJson(needs.host.outputs.val).announcement_title }} 264 | body: ${{ fromJson(needs.host.outputs.val).announcement_github_body }} 265 | prerelease: ${{ fromJson(needs.host.outputs.val).announcement_is_prerelease }} 266 | artifacts: "artifacts/*" 267 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target/ 2 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "aho-corasick" 7 | version = "1.1.3" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "ansi_term" 16 | version = "0.12.1" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" 19 | dependencies = [ 20 | "winapi", 21 | ] 22 | 23 | [[package]] 24 | name = "anstream" 25 | version = "0.6.13" 26 | source = "registry+https://github.com/rust-lang/crates.io-index" 27 | checksum = "d96bd03f33fe50a863e394ee9718a706f988b9079b20c3784fb726e7678b62fb" 28 | dependencies = [ 29 | "anstyle", 30 | "anstyle-parse", 31 | "anstyle-query", 32 | "anstyle-wincon", 33 | "colorchoice", 34 | "utf8parse", 35 | ] 36 | 37 | [[package]] 38 | name = "anstyle" 39 | version = "1.0.6" 40 | source = "registry+https://github.com/rust-lang/crates.io-index" 41 | checksum = "8901269c6307e8d93993578286ac0edf7f195079ffff5ebdeea6a59ffb7e36bc" 42 | 43 | [[package]] 44 | name = "anstyle-parse" 45 | version = "0.2.3" 46 | source = "registry+https://github.com/rust-lang/crates.io-index" 47 | checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c" 48 | dependencies = [ 49 | "utf8parse", 50 | ] 51 | 52 | [[package]] 53 | name = "anstyle-query" 54 | version = "1.0.2" 55 | source = "registry+https://github.com/rust-lang/crates.io-index" 56 | checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648" 57 | dependencies = [ 58 | "windows-sys", 59 | ] 60 | 61 | [[package]] 62 | name = "anstyle-wincon" 63 | version = "3.0.2" 64 | source = "registry+https://github.com/rust-lang/crates.io-index" 65 | checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7" 66 | dependencies = [ 67 | "anstyle", 68 | "windows-sys", 69 | ] 70 | 71 | [[package]] 72 | name = "anyhow" 73 | version = "1.0.81" 74 | source = "registry+https://github.com/rust-lang/crates.io-index" 75 | checksum = "0952808a6c2afd1aa8947271f3a60f1a6763c7b912d210184c5149b5cf147247" 76 | 77 | [[package]] 78 | name = "bitflags" 79 | version = "1.3.2" 80 | source = "registry+https://github.com/rust-lang/crates.io-index" 81 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 82 | 83 | [[package]] 84 | name = "bitflags" 85 | version = "2.5.0" 86 | source = "registry+https://github.com/rust-lang/crates.io-index" 87 | checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" 88 | 89 | [[package]] 90 | name = "bstr" 91 | version = "1.9.1" 92 | source = "registry+https://github.com/rust-lang/crates.io-index" 93 | checksum = "05efc5cfd9110c8416e471df0e96702d58690178e206e61b7173706673c93706" 94 | dependencies = [ 95 | "memchr", 96 | "serde", 97 | ] 98 | 99 | [[package]] 100 | name = "cc" 101 | version = "1.0.90" 102 | source = "registry+https://github.com/rust-lang/crates.io-index" 103 | checksum = "8cd6604a82acf3039f1144f54b8eb34e91ffba622051189e71b781822d5ee1f5" 104 | dependencies = [ 105 | "jobserver", 106 | "libc", 107 | ] 108 | 109 | [[package]] 110 | name = "cfg-if" 111 | version = "1.0.0" 112 | source = "registry+https://github.com/rust-lang/crates.io-index" 113 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 114 | 115 | [[package]] 116 | name = "clap" 117 | version = "4.5.3" 118 | source = "registry+https://github.com/rust-lang/crates.io-index" 119 | checksum = "949626d00e063efc93b6dca932419ceb5432f99769911c0b995f7e884c778813" 120 | dependencies = [ 121 | "clap_builder", 122 | "clap_derive", 123 | ] 124 | 125 | [[package]] 126 | name = "clap_builder" 127 | version = "4.5.2" 128 | source = "registry+https://github.com/rust-lang/crates.io-index" 129 | checksum = "ae129e2e766ae0ec03484e609954119f123cc1fe650337e155d03b022f24f7b4" 130 | dependencies = [ 131 | "anstream", 132 | "anstyle", 133 | "clap_lex", 134 | "strsim", 135 | ] 136 | 137 | [[package]] 138 | name = "clap_derive" 139 | version = "4.5.3" 140 | source = "registry+https://github.com/rust-lang/crates.io-index" 141 | checksum = "90239a040c80f5e14809ca132ddc4176ab33d5e17e49691793296e3fcb34d72f" 142 | dependencies = [ 143 | "heck", 144 | "proc-macro2", 145 | "quote", 146 | "syn", 147 | ] 148 | 149 | [[package]] 150 | name = "clap_lex" 151 | version = "0.7.0" 152 | source = "registry+https://github.com/rust-lang/crates.io-index" 153 | checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce" 154 | 155 | [[package]] 156 | name = "colorchoice" 157 | version = "1.0.0" 158 | source = "registry+https://github.com/rust-lang/crates.io-index" 159 | checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" 160 | 161 | [[package]] 162 | name = "crossbeam-deque" 163 | version = "0.8.5" 164 | source = "registry+https://github.com/rust-lang/crates.io-index" 165 | checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" 166 | dependencies = [ 167 | "crossbeam-epoch", 168 | "crossbeam-utils", 169 | ] 170 | 171 | [[package]] 172 | name = "crossbeam-epoch" 173 | version = "0.9.18" 174 | source = "registry+https://github.com/rust-lang/crates.io-index" 175 | checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" 176 | dependencies = [ 177 | "crossbeam-utils", 178 | ] 179 | 180 | [[package]] 181 | name = "crossbeam-utils" 182 | version = "0.8.19" 183 | source = "registry+https://github.com/rust-lang/crates.io-index" 184 | checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" 185 | 186 | [[package]] 187 | name = "dirs-next" 188 | version = "2.0.0" 189 | source = "registry+https://github.com/rust-lang/crates.io-index" 190 | checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" 191 | dependencies = [ 192 | "cfg-if", 193 | "dirs-sys-next", 194 | ] 195 | 196 | [[package]] 197 | name = "dirs-sys-next" 198 | version = "0.1.2" 199 | source = "registry+https://github.com/rust-lang/crates.io-index" 200 | checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" 201 | dependencies = [ 202 | "libc", 203 | "redox_users", 204 | "winapi", 205 | ] 206 | 207 | [[package]] 208 | name = "equivalent" 209 | version = "1.0.1" 210 | source = "registry+https://github.com/rust-lang/crates.io-index" 211 | checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" 212 | 213 | [[package]] 214 | name = "form_urlencoded" 215 | version = "1.2.1" 216 | source = "registry+https://github.com/rust-lang/crates.io-index" 217 | checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" 218 | dependencies = [ 219 | "percent-encoding", 220 | ] 221 | 222 | [[package]] 223 | name = "getrandom" 224 | version = "0.2.12" 225 | source = "registry+https://github.com/rust-lang/crates.io-index" 226 | checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" 227 | dependencies = [ 228 | "cfg-if", 229 | "libc", 230 | "wasi", 231 | ] 232 | 233 | [[package]] 234 | name = "git2" 235 | version = "0.18.3" 236 | source = "registry+https://github.com/rust-lang/crates.io-index" 237 | checksum = "232e6a7bfe35766bf715e55a88b39a700596c0ccfd88cd3680b4cdb40d66ef70" 238 | dependencies = [ 239 | "bitflags 2.5.0", 240 | "libc", 241 | "libgit2-sys", 242 | "log", 243 | "openssl-probe", 244 | "openssl-sys", 245 | "url", 246 | ] 247 | 248 | [[package]] 249 | name = "globset" 250 | version = "0.4.14" 251 | source = "registry+https://github.com/rust-lang/crates.io-index" 252 | checksum = "57da3b9b5b85bd66f31093f8c408b90a74431672542466497dcbdfdc02034be1" 253 | dependencies = [ 254 | "aho-corasick", 255 | "bstr", 256 | "log", 257 | "regex-automata", 258 | "regex-syntax", 259 | ] 260 | 261 | [[package]] 262 | name = "hashbrown" 263 | version = "0.14.3" 264 | source = "registry+https://github.com/rust-lang/crates.io-index" 265 | checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" 266 | 267 | [[package]] 268 | name = "heck" 269 | version = "0.5.0" 270 | source = "registry+https://github.com/rust-lang/crates.io-index" 271 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 272 | 273 | [[package]] 274 | name = "idna" 275 | version = "0.5.0" 276 | source = "registry+https://github.com/rust-lang/crates.io-index" 277 | checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" 278 | dependencies = [ 279 | "unicode-bidi", 280 | "unicode-normalization", 281 | ] 282 | 283 | [[package]] 284 | name = "ignore" 285 | version = "0.4.22" 286 | source = "registry+https://github.com/rust-lang/crates.io-index" 287 | checksum = "b46810df39e66e925525d6e38ce1e7f6e1d208f72dc39757880fcb66e2c58af1" 288 | dependencies = [ 289 | "crossbeam-deque", 290 | "globset", 291 | "log", 292 | "memchr", 293 | "regex-automata", 294 | "same-file", 295 | "walkdir", 296 | "winapi-util", 297 | ] 298 | 299 | [[package]] 300 | name = "indexmap" 301 | version = "2.2.6" 302 | source = "registry+https://github.com/rust-lang/crates.io-index" 303 | checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" 304 | dependencies = [ 305 | "equivalent", 306 | "hashbrown", 307 | ] 308 | 309 | [[package]] 310 | name = "itoa" 311 | version = "1.0.10" 312 | source = "registry+https://github.com/rust-lang/crates.io-index" 313 | checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" 314 | 315 | [[package]] 316 | name = "jobserver" 317 | version = "0.1.28" 318 | source = "registry+https://github.com/rust-lang/crates.io-index" 319 | checksum = "ab46a6e9526ddef3ae7f787c06f0f2600639ba80ea3eade3d8e670a2230f51d6" 320 | dependencies = [ 321 | "libc", 322 | ] 323 | 324 | [[package]] 325 | name = "libc" 326 | version = "0.2.153" 327 | source = "registry+https://github.com/rust-lang/crates.io-index" 328 | checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" 329 | 330 | [[package]] 331 | name = "libgit2-sys" 332 | version = "0.16.2+1.7.2" 333 | source = "registry+https://github.com/rust-lang/crates.io-index" 334 | checksum = "ee4126d8b4ee5c9d9ea891dd875cfdc1e9d0950437179104b183d7d8a74d24e8" 335 | dependencies = [ 336 | "cc", 337 | "libc", 338 | "libssh2-sys", 339 | "libz-sys", 340 | "openssl-sys", 341 | "pkg-config", 342 | ] 343 | 344 | [[package]] 345 | name = "libredox" 346 | version = "0.0.1" 347 | source = "registry+https://github.com/rust-lang/crates.io-index" 348 | checksum = "85c833ca1e66078851dba29046874e38f08b2c883700aa29a03ddd3b23814ee8" 349 | dependencies = [ 350 | "bitflags 2.5.0", 351 | "libc", 352 | "redox_syscall", 353 | ] 354 | 355 | [[package]] 356 | name = "libssh2-sys" 357 | version = "0.3.0" 358 | source = "registry+https://github.com/rust-lang/crates.io-index" 359 | checksum = "2dc8a030b787e2119a731f1951d6a773e2280c660f8ec4b0f5e1505a386e71ee" 360 | dependencies = [ 361 | "cc", 362 | "libc", 363 | "libz-sys", 364 | "openssl-sys", 365 | "pkg-config", 366 | "vcpkg", 367 | ] 368 | 369 | [[package]] 370 | name = "libz-sys" 371 | version = "1.1.16" 372 | source = "registry+https://github.com/rust-lang/crates.io-index" 373 | checksum = "5e143b5e666b2695d28f6bca6497720813f699c9602dd7f5cac91008b8ada7f9" 374 | dependencies = [ 375 | "cc", 376 | "libc", 377 | "pkg-config", 378 | "vcpkg", 379 | ] 380 | 381 | [[package]] 382 | name = "log" 383 | version = "0.4.21" 384 | source = "registry+https://github.com/rust-lang/crates.io-index" 385 | checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" 386 | 387 | [[package]] 388 | name = "memchr" 389 | version = "2.7.1" 390 | source = "registry+https://github.com/rust-lang/crates.io-index" 391 | checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" 392 | 393 | [[package]] 394 | name = "mrh" 395 | version = "0.13.2" 396 | dependencies = [ 397 | "ansi_term", 398 | "anyhow", 399 | "clap", 400 | "dirs-next", 401 | "git2", 402 | "ignore", 403 | "indexmap", 404 | "serde", 405 | "serde_json", 406 | ] 407 | 408 | [[package]] 409 | name = "openssl-probe" 410 | version = "0.1.5" 411 | source = "registry+https://github.com/rust-lang/crates.io-index" 412 | checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" 413 | 414 | [[package]] 415 | name = "openssl-sys" 416 | version = "0.9.101" 417 | source = "registry+https://github.com/rust-lang/crates.io-index" 418 | checksum = "dda2b0f344e78efc2facf7d195d098df0dd72151b26ab98da807afc26c198dff" 419 | dependencies = [ 420 | "cc", 421 | "libc", 422 | "pkg-config", 423 | "vcpkg", 424 | ] 425 | 426 | [[package]] 427 | name = "percent-encoding" 428 | version = "2.3.1" 429 | source = "registry+https://github.com/rust-lang/crates.io-index" 430 | checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" 431 | 432 | [[package]] 433 | name = "pkg-config" 434 | version = "0.3.30" 435 | source = "registry+https://github.com/rust-lang/crates.io-index" 436 | checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" 437 | 438 | [[package]] 439 | name = "proc-macro2" 440 | version = "1.0.79" 441 | source = "registry+https://github.com/rust-lang/crates.io-index" 442 | checksum = "e835ff2298f5721608eb1a980ecaee1aef2c132bf95ecc026a11b7bf3c01c02e" 443 | dependencies = [ 444 | "unicode-ident", 445 | ] 446 | 447 | [[package]] 448 | name = "quote" 449 | version = "1.0.35" 450 | source = "registry+https://github.com/rust-lang/crates.io-index" 451 | checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" 452 | dependencies = [ 453 | "proc-macro2", 454 | ] 455 | 456 | [[package]] 457 | name = "redox_syscall" 458 | version = "0.4.1" 459 | source = "registry+https://github.com/rust-lang/crates.io-index" 460 | checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" 461 | dependencies = [ 462 | "bitflags 1.3.2", 463 | ] 464 | 465 | [[package]] 466 | name = "redox_users" 467 | version = "0.4.4" 468 | source = "registry+https://github.com/rust-lang/crates.io-index" 469 | checksum = "a18479200779601e498ada4e8c1e1f50e3ee19deb0259c25825a98b5603b2cb4" 470 | dependencies = [ 471 | "getrandom", 472 | "libredox", 473 | "thiserror", 474 | ] 475 | 476 | [[package]] 477 | name = "regex-automata" 478 | version = "0.4.6" 479 | source = "registry+https://github.com/rust-lang/crates.io-index" 480 | checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea" 481 | dependencies = [ 482 | "aho-corasick", 483 | "memchr", 484 | "regex-syntax", 485 | ] 486 | 487 | [[package]] 488 | name = "regex-syntax" 489 | version = "0.8.2" 490 | source = "registry+https://github.com/rust-lang/crates.io-index" 491 | checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" 492 | 493 | [[package]] 494 | name = "ryu" 495 | version = "1.0.17" 496 | source = "registry+https://github.com/rust-lang/crates.io-index" 497 | checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" 498 | 499 | [[package]] 500 | name = "same-file" 501 | version = "1.0.6" 502 | source = "registry+https://github.com/rust-lang/crates.io-index" 503 | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 504 | dependencies = [ 505 | "winapi-util", 506 | ] 507 | 508 | [[package]] 509 | name = "serde" 510 | version = "1.0.197" 511 | source = "registry+https://github.com/rust-lang/crates.io-index" 512 | checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" 513 | dependencies = [ 514 | "serde_derive", 515 | ] 516 | 517 | [[package]] 518 | name = "serde_derive" 519 | version = "1.0.197" 520 | source = "registry+https://github.com/rust-lang/crates.io-index" 521 | checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" 522 | dependencies = [ 523 | "proc-macro2", 524 | "quote", 525 | "syn", 526 | ] 527 | 528 | [[package]] 529 | name = "serde_json" 530 | version = "1.0.114" 531 | source = "registry+https://github.com/rust-lang/crates.io-index" 532 | checksum = "c5f09b1bd632ef549eaa9f60a1f8de742bdbc698e6cee2095fc84dde5f549ae0" 533 | dependencies = [ 534 | "itoa", 535 | "ryu", 536 | "serde", 537 | ] 538 | 539 | [[package]] 540 | name = "strsim" 541 | version = "0.11.0" 542 | source = "registry+https://github.com/rust-lang/crates.io-index" 543 | checksum = "5ee073c9e4cd00e28217186dbe12796d692868f432bf2e97ee73bed0c56dfa01" 544 | 545 | [[package]] 546 | name = "syn" 547 | version = "2.0.55" 548 | source = "registry+https://github.com/rust-lang/crates.io-index" 549 | checksum = "002a1b3dbf967edfafc32655d0f377ab0bb7b994aa1d32c8cc7e9b8bf3ebb8f0" 550 | dependencies = [ 551 | "proc-macro2", 552 | "quote", 553 | "unicode-ident", 554 | ] 555 | 556 | [[package]] 557 | name = "thiserror" 558 | version = "1.0.58" 559 | source = "registry+https://github.com/rust-lang/crates.io-index" 560 | checksum = "03468839009160513471e86a034bb2c5c0e4baae3b43f79ffc55c4a5427b3297" 561 | dependencies = [ 562 | "thiserror-impl", 563 | ] 564 | 565 | [[package]] 566 | name = "thiserror-impl" 567 | version = "1.0.58" 568 | source = "registry+https://github.com/rust-lang/crates.io-index" 569 | checksum = "c61f3ba182994efc43764a46c018c347bc492c79f024e705f46567b418f6d4f7" 570 | dependencies = [ 571 | "proc-macro2", 572 | "quote", 573 | "syn", 574 | ] 575 | 576 | [[package]] 577 | name = "tinyvec" 578 | version = "1.6.0" 579 | source = "registry+https://github.com/rust-lang/crates.io-index" 580 | checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" 581 | dependencies = [ 582 | "tinyvec_macros", 583 | ] 584 | 585 | [[package]] 586 | name = "tinyvec_macros" 587 | version = "0.1.1" 588 | source = "registry+https://github.com/rust-lang/crates.io-index" 589 | checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" 590 | 591 | [[package]] 592 | name = "unicode-bidi" 593 | version = "0.3.15" 594 | source = "registry+https://github.com/rust-lang/crates.io-index" 595 | checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" 596 | 597 | [[package]] 598 | name = "unicode-ident" 599 | version = "1.0.12" 600 | source = "registry+https://github.com/rust-lang/crates.io-index" 601 | checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" 602 | 603 | [[package]] 604 | name = "unicode-normalization" 605 | version = "0.1.23" 606 | source = "registry+https://github.com/rust-lang/crates.io-index" 607 | checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" 608 | dependencies = [ 609 | "tinyvec", 610 | ] 611 | 612 | [[package]] 613 | name = "url" 614 | version = "2.5.0" 615 | source = "registry+https://github.com/rust-lang/crates.io-index" 616 | checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" 617 | dependencies = [ 618 | "form_urlencoded", 619 | "idna", 620 | "percent-encoding", 621 | ] 622 | 623 | [[package]] 624 | name = "utf8parse" 625 | version = "0.2.1" 626 | source = "registry+https://github.com/rust-lang/crates.io-index" 627 | checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" 628 | 629 | [[package]] 630 | name = "vcpkg" 631 | version = "0.2.15" 632 | source = "registry+https://github.com/rust-lang/crates.io-index" 633 | checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 634 | 635 | [[package]] 636 | name = "walkdir" 637 | version = "2.5.0" 638 | source = "registry+https://github.com/rust-lang/crates.io-index" 639 | checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" 640 | dependencies = [ 641 | "same-file", 642 | "winapi-util", 643 | ] 644 | 645 | [[package]] 646 | name = "wasi" 647 | version = "0.11.0+wasi-snapshot-preview1" 648 | source = "registry+https://github.com/rust-lang/crates.io-index" 649 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 650 | 651 | [[package]] 652 | name = "winapi" 653 | version = "0.3.9" 654 | source = "registry+https://github.com/rust-lang/crates.io-index" 655 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 656 | dependencies = [ 657 | "winapi-i686-pc-windows-gnu", 658 | "winapi-x86_64-pc-windows-gnu", 659 | ] 660 | 661 | [[package]] 662 | name = "winapi-i686-pc-windows-gnu" 663 | version = "0.4.0" 664 | source = "registry+https://github.com/rust-lang/crates.io-index" 665 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 666 | 667 | [[package]] 668 | name = "winapi-util" 669 | version = "0.1.6" 670 | source = "registry+https://github.com/rust-lang/crates.io-index" 671 | checksum = "f29e6f9198ba0d26b4c9f07dbe6f9ed633e1f3d5b8b414090084349e46a52596" 672 | dependencies = [ 673 | "winapi", 674 | ] 675 | 676 | [[package]] 677 | name = "winapi-x86_64-pc-windows-gnu" 678 | version = "0.4.0" 679 | source = "registry+https://github.com/rust-lang/crates.io-index" 680 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 681 | 682 | [[package]] 683 | name = "windows-sys" 684 | version = "0.52.0" 685 | source = "registry+https://github.com/rust-lang/crates.io-index" 686 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 687 | dependencies = [ 688 | "windows-targets", 689 | ] 690 | 691 | [[package]] 692 | name = "windows-targets" 693 | version = "0.52.4" 694 | source = "registry+https://github.com/rust-lang/crates.io-index" 695 | checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b" 696 | dependencies = [ 697 | "windows_aarch64_gnullvm", 698 | "windows_aarch64_msvc", 699 | "windows_i686_gnu", 700 | "windows_i686_msvc", 701 | "windows_x86_64_gnu", 702 | "windows_x86_64_gnullvm", 703 | "windows_x86_64_msvc", 704 | ] 705 | 706 | [[package]] 707 | name = "windows_aarch64_gnullvm" 708 | version = "0.52.4" 709 | source = "registry+https://github.com/rust-lang/crates.io-index" 710 | checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9" 711 | 712 | [[package]] 713 | name = "windows_aarch64_msvc" 714 | version = "0.52.4" 715 | source = "registry+https://github.com/rust-lang/crates.io-index" 716 | checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675" 717 | 718 | [[package]] 719 | name = "windows_i686_gnu" 720 | version = "0.52.4" 721 | source = "registry+https://github.com/rust-lang/crates.io-index" 722 | checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3" 723 | 724 | [[package]] 725 | name = "windows_i686_msvc" 726 | version = "0.52.4" 727 | source = "registry+https://github.com/rust-lang/crates.io-index" 728 | checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02" 729 | 730 | [[package]] 731 | name = "windows_x86_64_gnu" 732 | version = "0.52.4" 733 | source = "registry+https://github.com/rust-lang/crates.io-index" 734 | checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03" 735 | 736 | [[package]] 737 | name = "windows_x86_64_gnullvm" 738 | version = "0.52.4" 739 | source = "registry+https://github.com/rust-lang/crates.io-index" 740 | checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177" 741 | 742 | [[package]] 743 | name = "windows_x86_64_msvc" 744 | version = "0.52.4" 745 | source = "registry+https://github.com/rust-lang/crates.io-index" 746 | checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" 747 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "mrh" 3 | version = "0.13.2" 4 | description = "Crawls filesystem and displays pending status of each git repo found" 5 | repository = "https://github.com/tshepang/mrh" 6 | license = "MIT OR Apache-2.0" 7 | categories = ["command-line-utilities"] 8 | keywords = ["git"] 9 | authors = ["Tshepang Mbambo "] 10 | edition = "2021" 11 | rust-version = "1.64" 12 | 13 | [[bin]] 14 | name = "mrh" 15 | required-features = ["cli"] 16 | 17 | [features] 18 | default = ["cli"] 19 | cli = ["dep:clap", "dep:ansi_term", "dep:anyhow"] 20 | json = ["dep:serde_json", "dep:serde", "cli"] 21 | 22 | [dependencies] 23 | dirs-next = "2" 24 | git2 = "0.18" 25 | indexmap = "2" 26 | 27 | [dependencies.ansi_term] 28 | version = "0.12" 29 | optional = true 30 | 31 | [dependencies.anyhow] 32 | version = "1" 33 | optional = true 34 | 35 | [dependencies.clap] 36 | version = "4" 37 | optional = true 38 | features = ["derive"] 39 | 40 | [dependencies.ignore] 41 | version = "0.4" 42 | default-features = false 43 | 44 | [dependencies.serde] 45 | version = "1" 46 | optional = true 47 | features = ["derive"] 48 | 49 | [dependencies.serde_json] 50 | version = "1" 51 | optional = true 52 | 53 | # generated by 'cargo dist init' 54 | [profile.dist] 55 | inherits = "release" 56 | lto = "thin" 57 | 58 | # Config for 'cargo dist' 59 | [workspace.metadata.dist] 60 | # The preferred cargo-dist version to use in CI (Cargo.toml SemVer syntax) 61 | cargo-dist-version = "0.12.0" 62 | # CI backends to support 63 | ci = ["github"] 64 | # The installers to generate for each app 65 | installers = ["shell", "powershell"] 66 | # Target platforms to build apps for (Rust target-triple syntax) 67 | targets = ["x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] 68 | # Publish jobs to run in CI 69 | pr-run-mode = "plan" 70 | # Features to pass to cargo build 71 | features = ["json"] 72 | # Whether to install an updater program 73 | install-updater = false 74 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any 2 | person obtaining a copy of this software and associated 3 | documentation files (the "Software"), to deal in the 4 | Software without restriction, including without 5 | limitation the rights to use, copy, modify, merge, 6 | publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software 8 | is furnished to do so, subject to the following 9 | conditions: 10 | 11 | The above copyright notice and this permission notice 12 | shall be included in all copies or substantial portions 13 | of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 17 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 18 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 19 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 22 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 23 | DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mrh - Multi-(git)Repo Helper 2 | 3 | > NOTE: 4 | > This is now developed [on Radicle]. 5 | > See [this website] for more on what that is. 6 | 7 | [on Radicle]: https://app.radicle.at/nodes/seed.radicle.at/rad:z37EycTqZeuGMYpUSCM3v2e2qe16s 8 | [this website]: https://radicle.xyz 9 | 10 | #### License 11 | 12 | 13 | Licensed under either of 14 | Apache License, Version 2.0 15 | or 16 | MIT license 17 | at your option. 18 | 19 | 20 |
21 | 22 | 23 | Unless you explicitly state otherwise, any contribution intentionally submitted 24 | for inclusion in this repo by you, as defined in the Apache-2.0 license, shall 25 | be dual licensed as above, without any additional terms or conditions. 26 | 27 | -------------------------------------------------------------------------------- /examples/basic.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | mrh::Crawler::new(".") 3 | .pending(true) 4 | .ignore_untracked(true) 5 | .ignore_uncommitted_repos(true) 6 | .for_each(|output| println!("{output:?}")); 7 | } 8 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! mrh - Multi-(git)Repo Helper 2 | //! 3 | //! A Git repo can be in a number of states where some pending actions may 4 | //! need to be taken: 5 | //! 6 | //! - uncommitted changes 7 | //! - unpushed commits 8 | //! - outdated branch 9 | //! - added files 10 | //! - deleted files 11 | //! - renamed files 12 | //! - untracked files (can be disabled) 13 | //! - uncommitted repos (can be disabled) 14 | //! - untagged HEAD (optional) 15 | //! - unpushed tags (optional) 16 | //! - unpulled tags (optional) 17 | //! - unfetched commits (optional) 18 | //! 19 | //! This library is meant to inspect those states, given a root path as 20 | //! starting point. 21 | //! 22 | //! Example: 23 | //! 24 | //! ``` 25 | //! # use std::path::Path; 26 | //! # fn main() { 27 | //! mrh::Crawler::new(".") 28 | //! .pending(true) 29 | //! .ignore_untracked(true) 30 | //! .ignore_uncommitted_repos(true) 31 | //! .for_each(|output| println!("{:?}", output)); 32 | //! # } 33 | //! ``` 34 | //! 35 | //! ## Feature flags 36 | //! 37 | //! - __`cli`__: enabled by default, this allows building the CLI 38 | //! - __`json`__: useful only when __`cli`__ feature is provided, 39 | //! this provides output in JSON format, to ease consumption by tools. 40 | 41 | use std::path::{Path, PathBuf}; 42 | 43 | use dirs_next as dirs; 44 | use git2::{Branch, Delta, Error, Repository, StatusOptions}; 45 | use indexmap::set::IndexSet as Set; 46 | 47 | /// Represents Crawler output 48 | /// 49 | /// There are 3 possible scenarios: 50 | /// 51 | /// - There are no pending states, so only `path` (to the repo) has a 52 | /// value 53 | /// - There are no pending states, and there is some error preventing the 54 | /// repo from being inspected properly... `error` will have `Some` value 55 | /// - There are pending states... `pending` will have `Some` value 56 | #[derive(Debug)] 57 | pub struct Output { 58 | /// Repository path 59 | pub path: PathBuf, 60 | /// A list of pending actions 61 | pub pending: Option>, 62 | /// Git-related error 63 | pub error: Option, 64 | } 65 | 66 | /// Crawls the filesystem, looking for Git repos 67 | pub struct Crawler { 68 | pending: bool, 69 | ignore_untracked: bool, 70 | ignore_uncommitted_repos: bool, 71 | absolute_paths: bool, 72 | untagged_heads: bool, 73 | access_remote: Option, 74 | root_path: PathBuf, 75 | iter: Box>, 76 | } 77 | 78 | impl Crawler { 79 | /// `root` is where crawling for Git repos begin 80 | pub fn new>(root: P) -> Self { 81 | Self { 82 | pending: false, 83 | ignore_untracked: false, 84 | ignore_uncommitted_repos: false, 85 | absolute_paths: false, 86 | untagged_heads: false, 87 | access_remote: None, 88 | root_path: root.as_ref().into(), 89 | iter: Box::new( 90 | ignore::WalkBuilder::new(root) 91 | .sort_by_file_path(|a, b| a.cmp(b)) 92 | .build() 93 | .filter_map(|entry| entry.ok()) // ignore stuff we can't read 94 | .filter(|entry| entry.file_type().is_some()) 95 | .filter(|entry| entry.file_type().unwrap().is_dir()) 96 | .filter_map(|entry| Repository::open(entry.path()).ok()), 97 | ), 98 | } 99 | } 100 | 101 | /// Decide if you only want matches that are in pending state 102 | pub const fn pending(mut self, answer: bool) -> Self { 103 | self.pending = answer; 104 | self 105 | } 106 | 107 | /// Decide if you want to exclude matches that have untracked files 108 | pub const fn ignore_untracked(mut self, answer: bool) -> Self { 109 | self.ignore_untracked = answer; 110 | self 111 | } 112 | 113 | /// Decide if you want to exclude repos that have no commits 114 | /// 115 | /// This will happen when a `git init` is executed, 116 | /// and one forgets to commit. 117 | pub const fn ignore_uncommitted_repos(mut self, answer: bool) -> Self { 118 | self.ignore_uncommitted_repos = answer; 119 | self 120 | } 121 | 122 | /// Display absolute paths (instead of relative ones) 123 | pub const fn absolute_paths(mut self, answer: bool) -> Self { 124 | self.absolute_paths = answer; 125 | self 126 | } 127 | 128 | /// Decide if you want matches whose HEADS are not tagged 129 | /// 130 | /// A use-case is where related repositories (e.g. those comprising 131 | /// a single system), need to be tagged before, say, a release 132 | pub const fn untagged_heads(mut self, answer: bool) -> Self { 133 | self.untagged_heads = answer; 134 | self 135 | } 136 | 137 | /// Allow access to the remote of the repo 138 | /// 139 | /// This allows checking if the repo is in sync with its remote counterpart, 140 | /// so will be relatively slow if remote is behind a network 141 | /// (which is the most likely scenario). 142 | /// 143 | /// # HTTP protocol remotes 144 | /// 145 | /// Uses Git's credentials.helper to determine what authentication 146 | /// method to use. 147 | /// If not successful: 148 | /// > error: an unknown git error occurred 149 | /// 150 | /// # Git protocol remotes 151 | /// 152 | /// If "ssh-key" is specified, the ssh key will be used for authentication. 153 | /// If "ssh-agent" is specified, a correctly-set ssh-agent will be assumed. 154 | /// This is useful for cases where passphrase is set on the ssh key, 155 | /// else you will get a: 156 | /// > error authenticating: no auth sock variable 157 | pub fn access_remote(mut self, ssh_auth_method: Option) -> Self { 158 | self.access_remote = ssh_auth_method; 159 | self 160 | } 161 | 162 | fn repo_ops(&self, repo: &Repository) -> Option { 163 | if let Some(path) = repo.workdir() { 164 | // ignore libgit2-sys test repos 165 | if git2::Repository::discover(path).is_err() { 166 | return None; 167 | } 168 | let mut pending = Set::new(); 169 | let mut path = path.to_path_buf(); 170 | if !self.absolute_paths { 171 | path = self.make_relative(&path); 172 | } 173 | let mut opts = StatusOptions::new(); 174 | opts.include_ignored(false) 175 | .include_untracked(true) 176 | .renames_head_to_index(true) 177 | .renames_index_to_workdir(true); 178 | let local_ref = match repo.head() { 179 | Ok(head) => head, 180 | Err(why) => { 181 | if self.ignore_uncommitted_repos 182 | && why.class() == git2::ErrorClass::Reference 183 | && why.code() == git2::ErrorCode::UnbornBranch 184 | { 185 | return None; 186 | } 187 | return Some(Output { 188 | path, 189 | pending: None, 190 | error: Some(why), 191 | }); 192 | } 193 | }; 194 | let local_branch = Branch::wrap(local_ref); 195 | let local_head_oid = match local_branch.get().target() { 196 | Some(oid) => oid, 197 | None => return None, 198 | }; 199 | match repo.statuses(Some(&mut opts)) { 200 | Ok(statuses) => { 201 | for status in statuses.iter() { 202 | pending = self.diff_ops(&status, pending); 203 | } 204 | if self.untagged_heads { 205 | let local_ref = local_branch.get(); 206 | if let Ok(tags) = repo.tag_names(None) { 207 | let mut untagged = true; 208 | for tag in tags.iter().flatten() { 209 | let tag = format!("refs/tags/{tag}"); 210 | if let Ok(reference) = repo.find_reference(&tag) { 211 | if &reference == local_ref { 212 | untagged = false; 213 | break; 214 | } 215 | } 216 | } 217 | if untagged { 218 | pending.insert("untagged HEAD"); 219 | } 220 | } 221 | } 222 | if let Ok(upstream_branch) = local_branch.upstream() { 223 | let upstream_ref = upstream_branch.into_reference(); 224 | let upstream_head_oid = match upstream_ref.target() { 225 | Some(oid) => oid, 226 | None => return None, 227 | }; 228 | if local_head_oid != upstream_head_oid { 229 | if let Ok((ahead, behind)) = 230 | repo.graph_ahead_behind(local_head_oid, upstream_head_oid) 231 | { 232 | if ahead > 0 { 233 | pending.insert("unpushed commits"); 234 | } 235 | if behind > 0 { 236 | pending.insert("outdated branch"); 237 | } 238 | } 239 | } 240 | } 241 | if self.access_remote.is_some() { 242 | pending = match self.remote_ops(repo, pending, local_head_oid) { 243 | Ok(pending) => pending, 244 | Err(why) => { 245 | return Some(Output { 246 | path, 247 | pending: None, 248 | error: Some(why), 249 | }); 250 | } 251 | } 252 | } 253 | if !pending.is_empty() { 254 | Some(Output { 255 | path, 256 | pending: Some(pending), 257 | error: None, 258 | }) 259 | } else if self.pending { 260 | None 261 | } else { 262 | Some(Output { 263 | path, 264 | pending: None, 265 | error: None, 266 | }) 267 | } 268 | } 269 | Err(why) => Some(Output { 270 | path, 271 | pending: None, 272 | error: Some(why), 273 | }), 274 | } 275 | } else { 276 | None 277 | } 278 | } 279 | 280 | fn diff_ops<'b>( 281 | &self, 282 | status: &git2::StatusEntry<'_>, 283 | mut pending: Set<&'b str>, 284 | ) -> Set<&'b str> { 285 | if let Some(diff_delta) = status.index_to_workdir() { 286 | match diff_delta.status() { 287 | Delta::Untracked => { 288 | if !self.ignore_untracked { 289 | pending.insert("untracked files"); 290 | } 291 | } 292 | Delta::Modified => { 293 | pending.insert("uncommitted changes"); 294 | } 295 | Delta::Deleted => { 296 | pending.insert("deleted files"); 297 | } 298 | Delta::Renamed => { 299 | pending.insert("renamed files"); 300 | } 301 | _ => (), 302 | } 303 | } 304 | if let Some(diff_delta) = status.head_to_index() { 305 | match diff_delta.status() { 306 | Delta::Added => { 307 | pending.insert("added files"); 308 | } 309 | Delta::Modified => { 310 | pending.insert("uncommitted changes"); 311 | } 312 | Delta::Deleted => { 313 | pending.insert("deleted files"); 314 | } 315 | Delta::Renamed => { 316 | pending.insert("renamed files"); 317 | } 318 | _ => (), 319 | } 320 | }; 321 | pending 322 | } 323 | 324 | fn remote_ops<'b>( 325 | &self, 326 | repo: &Repository, 327 | mut pending: Set<&'b str>, 328 | local_head_oid: git2::Oid, 329 | ) -> Result, Error> { 330 | if let Ok(remote) = repo.find_remote("origin") { 331 | // XXX howto avoid the following panic 332 | let config = git2::Config::open_default().expect("could not get git config"); 333 | let url = match remote.url() { 334 | Some(url) => url, 335 | // XXX should not ignore this one, though it seems not a likely one to occur 336 | None => return Ok(pending), 337 | }; 338 | let mut callbacks = git2::RemoteCallbacks::new(); 339 | if url.starts_with("http") { 340 | callbacks.credentials(|_, _, _| git2::Cred::credential_helper(&config, url, None)); 341 | } else if url.starts_with("git") { 342 | // github, bitbucket, and gitlab use "git" as ssh username 343 | if let Some(ref method) = self.access_remote { 344 | if method == "ssh-key" { 345 | for file_name in &[ 346 | "id_dsa", 347 | "id_ecdsa", 348 | "id_ecdsa_sk", 349 | "id_ed25519", 350 | "id_ed25519_sk", 351 | "id_rsa", 352 | ] { 353 | if let Some(home_dir) = dirs::home_dir() { 354 | let private_key = home_dir.join(".ssh").join(file_name); 355 | if private_key.exists() { 356 | callbacks.credentials(move |_, _, _| { 357 | git2::Cred::ssh_key("git", None, &private_key, None) 358 | }); 359 | break; 360 | } 361 | } 362 | } 363 | } else if method == "ssh-agent" { 364 | callbacks.credentials(|_, _, _| git2::Cred::ssh_key_from_agent("git")); 365 | } 366 | } 367 | } 368 | // avoid "cannot borrow immutable local variable `remote` as mutable" 369 | let mut remote = remote.clone(); 370 | remote.connect_auth(git2::Direction::Fetch, Some(callbacks), None)?; 371 | let mut remote_tags = Set::new(); 372 | if let Ok(remote_list) = remote.list() { 373 | for item in remote_list { 374 | let name = item.name(); 375 | if name.starts_with("refs/tags/") { 376 | // This weirdness of a postfix appears on some remote tags 377 | if !name.ends_with("^{}") { 378 | remote_tags.insert((item.name().to_string(), item.oid())); 379 | } 380 | } else if name.starts_with("refs/heads") && item.oid() != local_head_oid { 381 | let mut found = false; 382 | if let Ok(branches) = repo.branches(None) { 383 | for branch in branches.flatten() { 384 | if let Some(oid) = branch.0.get().target() { 385 | if oid == item.oid() { 386 | found = true; 387 | break; 388 | } 389 | } 390 | } 391 | } 392 | if !found { 393 | pending.insert("unfetched commits"); 394 | } 395 | } 396 | } 397 | let mut local_tags = Set::new(); 398 | if let Ok(tags) = repo.tag_names(None) { 399 | for tag in tags.iter().flatten() { 400 | let tag = format!("refs/tags/{tag}"); 401 | if let Ok(reference) = repo.find_reference(&tag) { 402 | if let Some(oid) = reference.target() { 403 | local_tags.insert((tag, oid)); 404 | } 405 | } 406 | } 407 | } 408 | if !local_tags.is_subset(&remote_tags) { 409 | pending.insert("unpushed tags"); 410 | } 411 | if !remote_tags.is_subset(&local_tags) { 412 | pending.insert("unpulled tags"); 413 | } 414 | } 415 | } 416 | Ok(pending) 417 | } 418 | 419 | fn make_relative(&self, target_dir: &Path) -> PathBuf { 420 | if let Ok(path) = target_dir.strip_prefix(&self.root_path) { 421 | if path.to_string_lossy().is_empty() { 422 | ".".into() 423 | } else { 424 | path.into() 425 | } 426 | } else { 427 | target_dir.into() 428 | } 429 | } 430 | } 431 | 432 | impl Iterator for Crawler { 433 | type Item = Output; 434 | fn next(&mut self) -> Option { 435 | loop { 436 | match self.iter.next() { 437 | None => return None, 438 | Some(repo) => { 439 | if let Some(output) = self.repo_ops(&repo) { 440 | return Some(output); 441 | } 442 | } 443 | } 444 | } 445 | } 446 | } 447 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "json")] 2 | use serde::Serialize; 3 | 4 | use std::{ 5 | fmt::Write as _, 6 | io::Write, 7 | path::{Path, PathBuf}, 8 | process, 9 | }; 10 | 11 | use ansi_term::Color; 12 | use anyhow::{bail, ensure, Result}; 13 | use clap::Parser; 14 | 15 | use mrh::Crawler; 16 | 17 | const CYAN: Color = Color::Fixed(6); 18 | const BRIGHT_BLACK: Color = Color::Fixed(8); 19 | const BRIGHT_RED: Color = Color::Fixed(9); 20 | 21 | #[derive(Parser)] 22 | #[command(about, version)] 23 | struct Cli { 24 | /// Only show repos with pending action 25 | #[arg(long)] 26 | pending: bool, 27 | /// Do not include untracked files in output 28 | #[arg(long)] 29 | ignore_untracked: bool, 30 | /// Do not include repos that have no commits 31 | #[arg(long)] 32 | ignore_uncommitted_repos: bool, 33 | /// Display absolute paths for repos 34 | #[arg(long)] 35 | absolute_paths: bool, 36 | /// Check if HEAD is untagged 37 | #[arg(long)] 38 | untagged_heads: bool, 39 | /// Compare against remote repo, most likely over the network 40 | #[arg(long, value_parser = ["ssh-key", "ssh-agent"])] 41 | ssh_auth_method: Option, 42 | /// Display output in JSON format 43 | #[arg(long)] 44 | output_json: bool, 45 | /// Choose a path where to start the crawl 46 | #[arg(default_value = ".")] 47 | root_path: PathBuf, 48 | } 49 | 50 | #[cfg(feature = "json")] 51 | #[derive(Serialize)] 52 | struct Output { 53 | pub path: String, 54 | pub pending: Option>, 55 | pub error: Option, 56 | } 57 | 58 | fn main() -> Result<()> { 59 | let cli = Cli::parse(); 60 | ensure!( 61 | cli.root_path.metadata()?.is_dir(), 62 | "root path should be a directory", 63 | ); 64 | let crawler = Crawler::new(&cli.root_path) 65 | .pending(cli.pending) 66 | .ignore_untracked(cli.ignore_untracked) 67 | .ignore_uncommitted_repos(cli.ignore_uncommitted_repos) 68 | .access_remote(cli.ssh_auth_method) 69 | .absolute_paths(cli.absolute_paths) 70 | .untagged_heads(cli.untagged_heads); 71 | for output in crawler { 72 | if cli.output_json { 73 | display_json(output); 74 | } else { 75 | display_human(output)?; 76 | } 77 | } 78 | Ok(()) 79 | } 80 | 81 | fn display_human(result: mrh::Output) -> Result<()> { 82 | #[cfg(windows)] 83 | ansi_term::enable_ansi_support().unwrap(); 84 | let current_dir = match std::env::current_dir() { 85 | Ok(dir) => dir, 86 | Err(why) => { 87 | bail!( 88 | "{}: Could not read current directory: {}", 89 | BRIGHT_RED.paint("error"), 90 | why, 91 | ); 92 | } 93 | }; 94 | let mut output = if let Ok(path) = result.path.strip_prefix(current_dir) { 95 | if path == Path::new("") { 96 | ".".into() 97 | } else { 98 | String::from(path.to_string_lossy()) 99 | } 100 | } else { 101 | String::from(result.path.to_string_lossy()) 102 | }; 103 | if let Some(pending) = result.pending { 104 | let pending: Vec<_> = pending.into_iter().collect(); 105 | write!(output, " ({})", CYAN.paint(pending.join(", ")))?; 106 | } 107 | if let Some(error) = result.error { 108 | write!( 109 | output, 110 | " ({}: {})", 111 | BRIGHT_RED.paint("error"), 112 | BRIGHT_BLACK.paint(error.to_string()), 113 | )?; 114 | } 115 | if let Err(why) = writeln!(std::io::stdout(), "{output}") { 116 | if why.kind() == std::io::ErrorKind::BrokenPipe { 117 | process::exit(1); 118 | } else { 119 | eprintln!("{why}"); 120 | } 121 | } 122 | Ok(()) 123 | } 124 | 125 | #[cfg(feature = "json")] 126 | fn make_serde_digestible(result: mrh::Output) -> Output { 127 | let path = result.path.to_string_lossy().to_string(); 128 | let pending = match result.pending { 129 | Some(pending) => { 130 | let vec: Vec<_> = pending.iter().map(|value| value.to_string()).collect(); 131 | Some(vec) 132 | } 133 | None => None, 134 | }; 135 | let error = result.error.map(|error| error.to_string()); 136 | Output { 137 | path, 138 | pending, 139 | error, 140 | } 141 | } 142 | 143 | #[cfg(feature = "json")] 144 | fn display_json(output: mrh::Output) { 145 | let output = make_serde_digestible(output); 146 | if let Err(why) = serde_json::to_writer(std::io::stdout(), &output) { 147 | eprintln!("{why}"); 148 | process::exit(1); 149 | } 150 | println!(); 151 | } 152 | #[cfg(not(feature = "json"))] 153 | fn display_json(_: mrh::Output) { 154 | eprintln!("Support for JSON output format not compiled in"); 155 | process::exit(1); 156 | } 157 | --------------------------------------------------------------------------------