├── .git-together ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── License.md ├── README.md ├── bats └── integration.bats ├── renovate.json └── src ├── author.rs ├── config.rs ├── errors.rs ├── git.rs ├── lib.rs └── main.rs /.git-together: -------------------------------------------------------------------------------- 1 | [git-together] 2 | domain = pivotal.io 3 | [git-together "authors"] 4 | ac = "Alpha Chen; achen" 5 | am = "Andres Medina; amedina" 6 | em = "Ehren Murdick; emurdick" 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | # branches-ignore: 4 | # - ci 5 | pull_request: 6 | branches: 7 | - master 8 | 9 | name: CI 10 | 11 | jobs: 12 | check: 13 | name: Check 14 | runs-on: ${{ matrix.os }} 15 | strategy: 16 | matrix: 17 | os: [ ubuntu-latest, macos-latest, windows-latest ] 18 | steps: 19 | - uses: actions/checkout@v3 20 | - run: cargo check 21 | 22 | test: 23 | name: Test Suite 24 | runs-on: ${{ matrix.os }} 25 | strategy: 26 | matrix: 27 | os: [ ubuntu-latest, macos-latest, windows-latest ] 28 | steps: 29 | - uses: actions/checkout@v3 30 | - run: cargo test 31 | 32 | fmt: 33 | name: Rustfmt 34 | runs-on: ${{ matrix.os }} 35 | strategy: 36 | matrix: 37 | os: [ ubuntu-latest, macos-latest, windows-latest ] 38 | steps: 39 | - uses: actions/checkout@v3 40 | - run: cargo fmt --all -- --check 41 | 42 | clippy: 43 | name: Clippy 44 | runs-on: ${{ matrix.os }} 45 | strategy: 46 | matrix: 47 | os: [ ubuntu-latest, macos-latest, windows-latest ] 48 | steps: 49 | - uses: actions/checkout@v3 50 | - run: cargo clippy -- -D warnings 51 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | # branches: 4 | # - ci 5 | tags: 6 | - 'v*' 7 | 8 | name: Release 9 | 10 | jobs: 11 | test: 12 | name: Test Suite 13 | runs-on: ${{ matrix.os }} 14 | strategy: 15 | matrix: 16 | os: [ ubuntu-latest, macos-latest, windows-latest ] 17 | steps: 18 | - uses: actions/checkout@v3 19 | - run: cargo test 20 | 21 | release: 22 | name: Create Release 23 | needs: test 24 | runs-on: ubuntu-latest 25 | outputs: 26 | upload_url: ${{ steps.create_release.outputs.upload_url }} 27 | steps: 28 | - name: Checkout code 29 | uses: actions/checkout@v3 30 | - name: Create Release 31 | id: create_release 32 | run: | 33 | upload_url="$( 34 | curl -L \ 35 | -X POST \ 36 | -H "Accept: application/vnd.github+json" \ 37 | -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ 38 | -H "X-GitHub-Api-Version: 2022-11-28" \ 39 | https://api.github.com/repos/${{ github.repository }}/releases \ 40 | -d '{"tag_name":"${{ github.ref_name }}","name":"${{ github.ref_name }}","body":"# ${{ github.ref_name }}","draft":true,"prerelease":false,"generate_release_notes":true}' \ 41 | | jq --raw-output .upload_url)" 42 | echo "upload_url=${upload_url/'{?name,label}'/}" >> "$GITHUB_OUTPUT" 43 | 44 | build: 45 | name: Build Binaries 46 | needs: release 47 | runs-on: ${{ matrix.os }} 48 | strategy: 49 | matrix: 50 | os: [ ubuntu-latest, macos-latest, windows-latest ] 51 | steps: 52 | - uses: actions/checkout@v3 53 | - run: cargo build --release 54 | 55 | - name: Create and Upload Native macOS Asset 56 | if: matrix.os == 'macos-latest' 57 | run: | 58 | rustup target add aarch64-apple-darwin 59 | cargo build --release --target aarch64-apple-darwin 60 | 61 | asset=git-together-${GITHUB_REF#refs/*/}-aarch64-apple-darwin.tar.gz 62 | ( 63 | cd target/aarch64-apple-darwin/release 64 | tar -zvc git-together > ${{ github.workspace }}/${asset} 65 | ) 66 | 67 | curl -L \ 68 | -X POST \ 69 | -H "Accept: application/vnd.github+json" \ 70 | -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ 71 | -H "X-GitHub-Api-Version: 2022-11-28" \ 72 | -H "Content-Type: application/gzip" \ 73 | "${{ needs.release.outputs.upload_url }}?name=${asset}" \ 74 | --data-binary "@${asset}" 75 | 76 | - name: Set Asset Names 77 | id: vars 78 | run: | 79 | triple=$(rustup show active-toolchain | awk '{print $1}') 80 | echo "windows_asset=git-together-${GITHUB_REF#refs/*/}-${triple}.zip" >> "$GITHUB_OUTPUT" 81 | echo "non_windows_asset=git-together-${GITHUB_REF#refs/*/}-${triple}.tar.gz" >> "$GITHUB_OUTPUT" 82 | shell: bash 83 | 84 | - name: Create Windows Asset 85 | if: matrix.os == 'windows-latest' 86 | run: | 87 | $SRC_DIR = $pwd.Path 88 | $STAGE = [System.Guid]::NewGuid().ToString() 89 | 90 | Set-Location $env:TEMP 91 | New-Item -Type Directory -Name $STAGE 92 | Set-Location $STAGE 93 | 94 | $ZIP = "$SRC_DIR\${{ steps.vars.outputs.windows_asset }}" 95 | 96 | Copy-Item "$SRC_DIR\target\release\git-together.exe" '.\' 97 | 98 | 7z a "$ZIP" * 99 | 100 | Remove-Item *.* -Force 101 | Set-Location .. 102 | Remove-Item $STAGE 103 | Set-Location $SRC_DIR 104 | 105 | - name: Create Non-Windows Asset 106 | if: matrix.os != 'windows-latest' 107 | run: | 108 | tar -zvc git-together > ${{ github.workspace }}/${{ steps.vars.outputs.non_windows_asset }} 109 | working-directory: target/release 110 | 111 | - name: Upload Windows Release Asset 112 | if: matrix.os == 'windows-latest' 113 | run: | 114 | curl -L ` 115 | -X POST ` 116 | -H "Accept: application/vnd.github+json" ` 117 | -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" ` 118 | -H "X-GitHub-Api-Version: 2022-11-28" ` 119 | -H "Content-Type: application/zip" ` 120 | "${{ needs.release.outputs.upload_url }}?name=${{ steps.vars.outputs.windows_asset }}" ` 121 | --data-binary "@${{ steps.vars.outputs.windows_asset }}" 122 | 123 | - name: Upload Non-Windows Release Asset 124 | if: matrix.os != 'windows-latest' 125 | run: | 126 | curl -L \ 127 | -X POST \ 128 | -H "Accept: application/vnd.github+json" \ 129 | -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ 130 | -H "X-GitHub-Api-Version: 2022-11-28" \ 131 | -H "Content-Type: application/gzip" \ 132 | "${{ needs.release.outputs.upload_url }}?name=${{ steps.vars.outputs.non_windows_asset }}" \ 133 | --data-binary "@${{ steps.vars.outputs.non_windows_asset }}" 134 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [Unreleased] 4 | 5 | ## [0.1.0-alpha.24] - 2020-07-28 6 | 7 | ### Fixed 8 | - [Don't crash with no arguments](https://github.com/kejadlen/git-together/pull/47) (@ipsi) 9 | 10 | ## [0.1.0-alpha.21] - 2019-11-07 11 | 12 | ### Added 13 | - [Skip global args when looking for command](https://github.com/kejadlen/git-together/pull/43) (@ipsi) 14 | 15 | ## [0.1.0-alpha.18] - 2019-08-24 16 | 17 | ### Added 18 | - [Return exit code from `git` subprcesses](https://github.com/kejadlen/git-together/pull/40) (@bradfordboyle) 19 | 20 | ## [0.1.0-alpha.17] - 2018-10-28 21 | 22 | ### Added 23 | - [Preliminary alias support](https://github.com/kejadlen/git-together/pull/27) (@sgravrock) 24 | - [Global support](https://github.com/kejadlen/git-together/pull/10) (@sentientmonkey, @nrxus, @professor) 25 | -------------------------------------------------------------------------------- /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 = "addr2line" 7 | version = "0.21.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" 10 | dependencies = [ 11 | "gimli", 12 | ] 13 | 14 | [[package]] 15 | name = "adler" 16 | version = "1.0.2" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" 19 | 20 | [[package]] 21 | name = "backtrace" 22 | version = "0.3.69" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" 25 | dependencies = [ 26 | "addr2line", 27 | "cc", 28 | "cfg-if", 29 | "libc", 30 | "miniz_oxide", 31 | "object", 32 | "rustc-demangle", 33 | ] 34 | 35 | [[package]] 36 | name = "bitflags" 37 | version = "1.3.2" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 40 | 41 | [[package]] 42 | name = "cc" 43 | version = "1.0.83" 44 | source = "registry+https://github.com/rust-lang/crates.io-index" 45 | checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" 46 | dependencies = [ 47 | "jobserver", 48 | "libc", 49 | ] 50 | 51 | [[package]] 52 | name = "cfg-if" 53 | version = "1.0.0" 54 | source = "registry+https://github.com/rust-lang/crates.io-index" 55 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 56 | 57 | [[package]] 58 | name = "error-chain" 59 | version = "0.12.4" 60 | source = "registry+https://github.com/rust-lang/crates.io-index" 61 | checksum = "2d2f06b9cac1506ece98fe3231e3cc9c4410ec3d5b1f24ae1c8946f0742cdefc" 62 | dependencies = [ 63 | "backtrace", 64 | "version_check", 65 | ] 66 | 67 | [[package]] 68 | name = "form_urlencoded" 69 | version = "1.2.0" 70 | source = "registry+https://github.com/rust-lang/crates.io-index" 71 | checksum = "a62bc1cf6f830c2ec14a513a9fb124d0a213a629668a4186f329db21fe045652" 72 | dependencies = [ 73 | "percent-encoding", 74 | ] 75 | 76 | [[package]] 77 | name = "gimli" 78 | version = "0.28.0" 79 | source = "registry+https://github.com/rust-lang/crates.io-index" 80 | checksum = "6fb8d784f27acf97159b40fc4db5ecd8aa23b9ad5ef69cdd136d3bc80665f0c0" 81 | 82 | [[package]] 83 | name = "git-together" 84 | version = "0.1.0-alpha.25" 85 | dependencies = [ 86 | "error-chain", 87 | "git2", 88 | ] 89 | 90 | [[package]] 91 | name = "git2" 92 | version = "0.17.2" 93 | source = "registry+https://github.com/rust-lang/crates.io-index" 94 | checksum = "7b989d6a7ca95a362cf2cfc5ad688b3a467be1f87e480b8dad07fee8c79b0044" 95 | dependencies = [ 96 | "bitflags", 97 | "libc", 98 | "libgit2-sys", 99 | "log", 100 | "openssl-probe", 101 | "openssl-sys", 102 | "url", 103 | ] 104 | 105 | [[package]] 106 | name = "idna" 107 | version = "0.4.0" 108 | source = "registry+https://github.com/rust-lang/crates.io-index" 109 | checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" 110 | dependencies = [ 111 | "unicode-bidi", 112 | "unicode-normalization", 113 | ] 114 | 115 | [[package]] 116 | name = "jobserver" 117 | version = "0.1.26" 118 | source = "registry+https://github.com/rust-lang/crates.io-index" 119 | checksum = "936cfd212a0155903bcbc060e316fb6cc7cbf2e1907329391ebadc1fe0ce77c2" 120 | dependencies = [ 121 | "libc", 122 | ] 123 | 124 | [[package]] 125 | name = "libc" 126 | version = "0.2.148" 127 | source = "registry+https://github.com/rust-lang/crates.io-index" 128 | checksum = "9cdc71e17332e86d2e1d38c1f99edcb6288ee11b815fb1a4b049eaa2114d369b" 129 | 130 | [[package]] 131 | name = "libgit2-sys" 132 | version = "0.15.2+1.6.4" 133 | source = "registry+https://github.com/rust-lang/crates.io-index" 134 | checksum = "a80df2e11fb4a61f4ba2ab42dbe7f74468da143f1a75c74e11dee7c813f694fa" 135 | dependencies = [ 136 | "cc", 137 | "libc", 138 | "libssh2-sys", 139 | "libz-sys", 140 | "openssl-sys", 141 | "pkg-config", 142 | ] 143 | 144 | [[package]] 145 | name = "libssh2-sys" 146 | version = "0.3.0" 147 | source = "registry+https://github.com/rust-lang/crates.io-index" 148 | checksum = "2dc8a030b787e2119a731f1951d6a773e2280c660f8ec4b0f5e1505a386e71ee" 149 | dependencies = [ 150 | "cc", 151 | "libc", 152 | "libz-sys", 153 | "openssl-sys", 154 | "pkg-config", 155 | "vcpkg", 156 | ] 157 | 158 | [[package]] 159 | name = "libz-sys" 160 | version = "1.1.12" 161 | source = "registry+https://github.com/rust-lang/crates.io-index" 162 | checksum = "d97137b25e321a73eef1418d1d5d2eda4d77e12813f8e6dead84bc52c5870a7b" 163 | dependencies = [ 164 | "cc", 165 | "libc", 166 | "pkg-config", 167 | "vcpkg", 168 | ] 169 | 170 | [[package]] 171 | name = "log" 172 | version = "0.4.20" 173 | source = "registry+https://github.com/rust-lang/crates.io-index" 174 | checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" 175 | 176 | [[package]] 177 | name = "memchr" 178 | version = "2.6.3" 179 | source = "registry+https://github.com/rust-lang/crates.io-index" 180 | checksum = "8f232d6ef707e1956a43342693d2a31e72989554d58299d7a88738cc95b0d35c" 181 | 182 | [[package]] 183 | name = "miniz_oxide" 184 | version = "0.7.1" 185 | source = "registry+https://github.com/rust-lang/crates.io-index" 186 | checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" 187 | dependencies = [ 188 | "adler", 189 | ] 190 | 191 | [[package]] 192 | name = "object" 193 | version = "0.32.1" 194 | source = "registry+https://github.com/rust-lang/crates.io-index" 195 | checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0" 196 | dependencies = [ 197 | "memchr", 198 | ] 199 | 200 | [[package]] 201 | name = "openssl-probe" 202 | version = "0.1.5" 203 | source = "registry+https://github.com/rust-lang/crates.io-index" 204 | checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" 205 | 206 | [[package]] 207 | name = "openssl-src" 208 | version = "300.1.5+3.1.3" 209 | source = "registry+https://github.com/rust-lang/crates.io-index" 210 | checksum = "559068e4c12950d7dcaa1857a61725c0d38d4fc03ff8e070ab31a75d6e316491" 211 | dependencies = [ 212 | "cc", 213 | ] 214 | 215 | [[package]] 216 | name = "openssl-sys" 217 | version = "0.9.93" 218 | source = "registry+https://github.com/rust-lang/crates.io-index" 219 | checksum = "db4d56a4c0478783083cfafcc42493dd4a981d41669da64b4572a2a089b51b1d" 220 | dependencies = [ 221 | "cc", 222 | "libc", 223 | "openssl-src", 224 | "pkg-config", 225 | "vcpkg", 226 | ] 227 | 228 | [[package]] 229 | name = "percent-encoding" 230 | version = "2.3.0" 231 | source = "registry+https://github.com/rust-lang/crates.io-index" 232 | checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" 233 | 234 | [[package]] 235 | name = "pkg-config" 236 | version = "0.3.27" 237 | source = "registry+https://github.com/rust-lang/crates.io-index" 238 | checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" 239 | 240 | [[package]] 241 | name = "rustc-demangle" 242 | version = "0.1.23" 243 | source = "registry+https://github.com/rust-lang/crates.io-index" 244 | checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" 245 | 246 | [[package]] 247 | name = "tinyvec" 248 | version = "1.6.0" 249 | source = "registry+https://github.com/rust-lang/crates.io-index" 250 | checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" 251 | dependencies = [ 252 | "tinyvec_macros", 253 | ] 254 | 255 | [[package]] 256 | name = "tinyvec_macros" 257 | version = "0.1.1" 258 | source = "registry+https://github.com/rust-lang/crates.io-index" 259 | checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" 260 | 261 | [[package]] 262 | name = "unicode-bidi" 263 | version = "0.3.13" 264 | source = "registry+https://github.com/rust-lang/crates.io-index" 265 | checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460" 266 | 267 | [[package]] 268 | name = "unicode-normalization" 269 | version = "0.1.22" 270 | source = "registry+https://github.com/rust-lang/crates.io-index" 271 | checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" 272 | dependencies = [ 273 | "tinyvec", 274 | ] 275 | 276 | [[package]] 277 | name = "url" 278 | version = "2.4.1" 279 | source = "registry+https://github.com/rust-lang/crates.io-index" 280 | checksum = "143b538f18257fac9cad154828a57c6bf5157e1aa604d4816b5995bf6de87ae5" 281 | dependencies = [ 282 | "form_urlencoded", 283 | "idna", 284 | "percent-encoding", 285 | ] 286 | 287 | [[package]] 288 | name = "vcpkg" 289 | version = "0.2.15" 290 | source = "registry+https://github.com/rust-lang/crates.io-index" 291 | checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 292 | 293 | [[package]] 294 | name = "version_check" 295 | version = "0.9.4" 296 | source = "registry+https://github.com/rust-lang/crates.io-index" 297 | checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" 298 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "git-together" 3 | version = "0.1.0-alpha.25" 4 | authors = ["Alpha Chen "] 5 | edition = "2021" 6 | 7 | [dependencies] 8 | error-chain = "0.12" 9 | git2 = { version = "0.17.1", features = ["vendored-openssl"] } 10 | -------------------------------------------------------------------------------- /License.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Alpha Chen 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 | # git-together 2 | 3 | ![CI](https://github.com/kejadlen/git-together/workflows/CI/badge.svg) 4 | 5 | Following in the footsteps of [git-pair][gp] and [git-duet][gd], but without 6 | needing to change your existing git habits. 7 | 8 | [gp]: https://github.com/pivotal/git_scripts 9 | [gd]: https://github.com/git-duet/git-duet 10 | 11 | ## Installation 12 | 13 | ```bash 14 | brew install kejadlen/git-together/git-together 15 | ``` 16 | 17 | ## Configuration 18 | 19 | Here's one way to configure `git-together`, but since it uses `git config` to 20 | store information, there are many other ways to do it. This particular example 21 | assumes a desire to store authors at the repo-level in a `.git-together` file. 22 | 23 | ```bash 24 | # `git-together` is meant to be aliased as `git` 25 | alias git=git-together 26 | 27 | # Use .git-together per project for author configuration 28 | git config --add include.path ../.git-together 29 | # Or use one .git-together for all projects 30 | git config --global --add include.path ~/.git-together 31 | 32 | # Setting the default domain 33 | git config --file .git-together --add git-together.domain rocinante.com 34 | 35 | # Adding a couple authors 36 | git config --file .git-together --add git-together.authors.jh 'James Holden; jholden' 37 | git config --file .git-together --add git-together.authors.nn 'Naomi Nagata; nnagata' 38 | 39 | # Adding an author with a different domain 40 | git config --file .git-together --add git-together.authors.ca 'Chrisjen Avasarala; avasarala@un.gov' 41 | ``` 42 | 43 | For completion with zsh, you'll need to update your `.zshrc` to copy the existing completion rules 44 | from the main git binary 45 | 46 | ```zsh 47 | # initialize the compinit system if not already 48 | autoload -U compinit 49 | compinit 50 | 51 | # tell zsh to use the completion setup for the git when using git-together 52 | compdef git-together=git 53 | ``` 54 | 55 | ## Usage 56 | 57 | ```bash 58 | # Pairing 59 | git with jh nn 60 | # ... 61 | git commit 62 | 63 | # Soloing 64 | git with nn 65 | # ... 66 | git commit 67 | 68 | # Mobbing 69 | git with jh nn ca 70 | # ... 71 | git commit 72 | ``` 73 | 74 | Soloing and mobbing are automatically set by the number of authors passed to 75 | `git with`. `git-together` rotates authors by default after making a commit so 76 | that the author/committer roles are fairly spread across the pair/mob over 77 | time. 78 | 79 | Aliases are supported as well. You can make git-together do its thing when you 80 | use an alias for a committing command by configuring a comma-separated list of 81 | aliases: 82 | 83 | ```bash 84 | git config git-together.aliases ci,rv,m 85 | # ... 86 | git ci 87 | ``` 88 | 89 | By default, `git-together` sets and rotates pairs for a single local 90 | repository. If you are working across multiple repos with a pair on a regular 91 | basis, this can be difficult to set across all of them. The `--global` flag can 92 | be passed along to set a global pair. `git-together` will still default to a 93 | local repository, so if you'd like to reset from local to global, you can use 94 | the `--clear` flag. 95 | 96 | ```bash 97 | # Set for all repos 98 | git with --global jh nn 99 | 100 | # Override in single repo 101 | git with nn 102 | 103 | # Clear local and move back to global 104 | git with --clear 105 | ``` 106 | 107 | ## Technical Details 108 | 109 | Because repo-level authors are common and there's no good way of configuring 110 | `git config` on cloning a repo, `git-together` will automatically include 111 | `.git-together` to `git config` if it exists. (See `GitConfig::auto_include` 112 | for details.) This allows `git-together` to work immediately on cloning a repo 113 | without manual configuration. 114 | 115 | Under the hood, `git-together` sets `GIT_AUTHOR_NAME`, `GIT_AUTHOR_EMAIL`, 116 | `GIT_COMMITTER_NAME`, and `GIT_COMMITTER_EMAIL` for the `commit`, `merge`, and 117 | `revert` subcommands so that git commits have the correct attribution.. 118 | `git-together` also adds the `--signoff` argument to the `commit` and `revert` 119 | subcommands so that the commit message includes the `Signed-off-by: ` line. 120 | 121 | ## Known Issues 122 | 123 | `git-together` works by aliasing `git` itself, so there are going to be issues 124 | with git's in-built aliases as well as other utilities (such as [Hub][hub]) 125 | that work in the same manner. 126 | 127 | [hub]: https://hub.github.com/ 128 | 129 | ## Development 130 | 131 | ### Rust version 132 | 133 | Install rust using the [rustup][rustup] tool. Installing from homebrew won't work 134 | because some nightly features of rust are needed to build. 135 | 136 | Then, switch to the nightly with 137 | 138 | ```bash 139 | rustup default nightly 140 | ``` 141 | 142 | ### Bats 143 | 144 | [Bats][bats] is a bash testing framework, used here for integration tests. This 145 | can be installed with homebrew. 146 | 147 | ```bash 148 | brew install bats 149 | ``` 150 | 151 | [rustup]: https://www.rustup.rs/ 152 | [bats]: https://github.com/sstephenson/bats 153 | 154 | ### Testing 155 | 156 | ```bash 157 | cargo test 158 | ./bats/integration.bats 159 | ``` 160 | -------------------------------------------------------------------------------- /bats/integration.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | @test "soloing" { 4 | git-together with jh 5 | touch foo 6 | git add foo 7 | git-together commit -m "add foo" 8 | 9 | run git show --no-patch --format="%aN <%aE>" 10 | [ "$output" = "James Holden " ] 11 | run git show --no-patch --format=%B 12 | [[ ! "$output" =~ "Signed-off-by:" ]] 13 | } 14 | 15 | @test "pairing" { 16 | git-together with jh nn 17 | touch foo 18 | git add foo 19 | git-together commit -m "add foo" 20 | 21 | run git show --no-patch --format="%aN <%aE>" 22 | [ "$output" = "James Holden " ] 23 | run git show --no-patch --format="%cN <%cE>" 24 | [ "$output" = "Naomi Nagata " ] 25 | run git show --no-patch --format=%B 26 | [[ "$output" =~ "Signed-off-by: Naomi Nagata " ]] 27 | } 28 | 29 | @test "rotation" { 30 | git-together with jh nn 31 | 32 | touch foo 33 | git add foo 34 | git-together commit -m "add foo" 35 | 36 | touch bar 37 | git add bar 38 | git-together commit -m "add bar" 39 | 40 | run git show --no-patch --format="%aN <%aE>" 41 | [ "$output" = "Naomi Nagata " ] 42 | run git show --no-patch --format="%cN <%cE>" 43 | [ "$output" = "James Holden " ] 44 | run git show --no-patch --format=%B 45 | [[ "$output" =~ "Signed-off-by: James Holden " ]] 46 | } 47 | 48 | @test "mobbing" { 49 | git-together with jh nn ca 50 | 51 | touch foo 52 | git add foo 53 | git-together commit -m "add foo" 54 | 55 | run git show --no-patch --format="%aN <%aE>" 56 | [ "$output" = "James Holden " ] 57 | run git show --no-patch --format="%cN <%cE>" 58 | [ "$output" = "Naomi Nagata " ] 59 | run git show --no-patch --format=%B 60 | [[ "$output" =~ "Signed-off-by: Naomi Nagata " ]] 61 | 62 | touch bar 63 | git add bar 64 | git-together commit -m "add bar" 65 | 66 | run git show --no-patch --format="%aN <%aE>" 67 | [ "$output" = "Naomi Nagata " ] 68 | run git show --no-patch --format="%cN <%cE>" 69 | [ "$output" = "Chrisjen Avasarala " ] 70 | run git show --no-patch --format=%B 71 | [[ "$output" =~ "Signed-off-by: Chrisjen Avasarala " ]] 72 | 73 | touch baz 74 | git add baz 75 | git-together commit -m "add baz" 76 | 77 | run git show --no-patch --format="%aN <%aE>" 78 | [ "$output" = "Chrisjen Avasarala " ] 79 | run git show --no-patch --format="%cN <%cE>" 80 | [ "$output" = "James Holden " ] 81 | run git show --no-patch --format=%B 82 | [[ "$output" =~ "Signed-off-by: James Holden " ]] 83 | } 84 | 85 | @test "auto-including .git-together" { 86 | git-together with jh 87 | run git config --local include.path 88 | [ "$status" -eq 1 ] 89 | 90 | touch .git-together 91 | 92 | git-together with jh 93 | run git config --local include.path 94 | [ "$output" = "../.git-together" ] 95 | 96 | git-together with jh 97 | run git config --local --get-all include.path 98 | [ "$output" = "../.git-together" ] 99 | } 100 | 101 | @test "list current authors" { 102 | git-together with jh nn 103 | 104 | run git-together with 105 | expected=$(cat < 107 | nn: Naomi Nagata 108 | AUTHORS 109 | ) 110 | [[ "$output" =~ "$expected" ]] 111 | 112 | run git config git-together.active 113 | [ "$output" = "jh+nn" ] 114 | } 115 | 116 | @test "list all authors" { 117 | git-together with jh 118 | 119 | run git-together with --list 120 | expected=$(cat < 122 | jh: James Holden 123 | nn: Naomi Nagata 124 | AUTHORS 125 | ) 126 | [[ "$output" =~ "$expected" ]] 127 | 128 | run git config git-together.active 129 | [ "$output" = "jh" ] 130 | } 131 | 132 | @test "no signoff" { 133 | git-together with jh nn 134 | touch foo 135 | git add foo 136 | GIT_TOGETHER_NO_SIGNOFF=1 git-together commit -m "add foo" 137 | 138 | run git show --no-patch --format="%aN <%aE>" 139 | [ "$output" = "James Holden " ] 140 | run git show --no-patch --format="%cN <%cE>" 141 | [ "$output" = "Naomi Nagata " ] 142 | run git show --no-patch --format=%B 143 | [[ ! "$output" =~ "Signed-off-by: Naomi Nagata " ]] 144 | } 145 | 146 | @test "merging" { 147 | git-together with jh nn 148 | touch foo 149 | git add foo 150 | git-together commit -m "add foo" 151 | 152 | git checkout -b bar 153 | touch bar 154 | git add bar 155 | git-together commit -m "add bar" 156 | 157 | git checkout - 158 | git-together merge --no-edit --no-ff bar 159 | 160 | run git show --no-patch --format="%aN <%aE>" 161 | [ "$output" = "James Holden " ] 162 | run git show --no-patch --format="%cN <%cE>" 163 | [ "$output" = "Naomi Nagata " ] 164 | run git show --no-patch --format=%B 165 | [[ ! "$output" =~ "Signed-off-by:" ]] 166 | } 167 | 168 | @test "reverting" { 169 | git-together with jh nn 170 | touch foo 171 | git add foo 172 | git-together commit -m "add foo" 173 | git-together revert --no-edit HEAD 174 | 175 | run git show --no-patch --format="%aN <%aE>" 176 | [ "$output" = "Naomi Nagata " ] 177 | run git show --no-patch --format="%cN <%cE>" 178 | [ "$output" = "James Holden " ] 179 | run git show --no-patch --format=%B 180 | [[ "$output" =~ "Signed-off-by: James Holden " ]] 181 | } 182 | 183 | @test "not in a git repo" { 184 | cd $BATS_TMPDIR 185 | 186 | run git-together with --list 187 | [ "$status" -eq 0 ] 188 | } 189 | 190 | @test "clear" { 191 | git-together with jh 192 | git-together with --clear 193 | 194 | run git config git-together.active 195 | [ "$output" = "" ] 196 | } 197 | 198 | @test "together" { 199 | git-together together jh nn 200 | touch foo 201 | git add foo 202 | git-together commit -m "add foo" 203 | 204 | run git show --no-patch --format="%aN <%aE>" 205 | [ "$output" = "James Holden " ] 206 | run git show --no-patch --format="%cN <%cE>" 207 | [ "$output" = "Naomi Nagata " ] 208 | run git show --no-patch --format=%B 209 | [[ "$output" =~ "Signed-off-by: Naomi Nagata " ]] 210 | } 211 | 212 | @test "aliases" { 213 | git config --local alias.ci commit 214 | git config --local git-together.aliases m,ci,r 215 | git-together with jh nn 216 | touch foo 217 | git add foo 218 | git-together ci -m "add foo" 219 | 220 | run git show --no-patch --format="%aN <%aE>" 221 | [ "$output" = "James Holden " ] 222 | run git show --no-patch --format="%cN <%cE>" 223 | [ "$output" = "Naomi Nagata " ] 224 | run git show --no-patch --format=%B 225 | [[ "$output" =~ "Signed-off-by: Naomi Nagata " ]] 226 | } 227 | 228 | @test "global args" { 229 | git-together with jh nn 230 | touch foo 231 | git add foo 232 | git-together -c commit.verbose=false commit -m "add foo" 233 | } 234 | 235 | setup() { 236 | # [ -f $BATS_TMPDIR/bin/git-together ] || cargo install --root $BATS_TMPDIR 237 | rm -rf $BATS_TMPDIR/bin 238 | cargo install --path . --root $BATS_TMPDIR 239 | PATH=$BATS_TMPDIR/bin:$PATH 240 | 241 | rm -rf $BATS_TMPDIR/$BATS_TEST_NAME 242 | mkdir -p $BATS_TMPDIR/$BATS_TEST_NAME 243 | cd $BATS_TMPDIR/$BATS_TEST_NAME 244 | 245 | git init 246 | git config --add git-together.domain rocinante.com 247 | git config --add git-together.authors.jh "James Holden; jholden" 248 | git config --add git-together.authors.nn "Naomi Nagata; nnagata" 249 | git config --add git-together.authors.ca "Chrisjen Avasarala; avasarala@un.gov" 250 | } 251 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /src/author.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | use crate::errors::*; 4 | 5 | #[derive(Clone, Debug, PartialEq)] 6 | pub struct Author { 7 | pub name: String, 8 | pub email: String, 9 | } 10 | 11 | pub struct AuthorParser { 12 | pub domain: Option, 13 | } 14 | 15 | impl AuthorParser { 16 | pub fn parse(&self, raw: &str) -> Result { 17 | let mut split = raw.split(';').map(str::trim); 18 | 19 | let name = match split.next() { 20 | Some(name) if !name.is_empty() => name, 21 | _ => { 22 | return Err("missing name".into()); 23 | } 24 | }; 25 | 26 | let email_seed = match split.next() { 27 | Some(email_seed) if !email_seed.is_empty() => email_seed, 28 | _ => { 29 | return Err("missing email seed".into()); 30 | } 31 | }; 32 | 33 | let email = if email_seed.contains('@') { 34 | email_seed.into() 35 | } else { 36 | let domain = match self.domain { 37 | Some(ref domain) => domain, 38 | None => { 39 | return Err("missing domain".into()); 40 | } 41 | }; 42 | format!("{}@{}", email_seed, domain) 43 | }; 44 | 45 | Ok(Author { 46 | name: name.into(), 47 | email, 48 | }) 49 | } 50 | } 51 | 52 | impl fmt::Display for Author { 53 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 54 | write!(f, "{} <{}>", self.name, self.email) 55 | } 56 | } 57 | 58 | #[cfg(test)] 59 | mod tests { 60 | use super::*; 61 | 62 | #[test] 63 | fn new() { 64 | let author_parser = AuthorParser { 65 | domain: Some("example.com".into()), 66 | }; 67 | 68 | let author = author_parser.parse("Jane Doe; jdoe").unwrap(); 69 | assert_eq!(author.name, "Jane Doe"); 70 | assert_eq!(author.email, "jdoe@example.com"); 71 | 72 | let author = author_parser.parse(""); 73 | assert!(author.is_err()); 74 | 75 | let author = author_parser.parse("Jane Doe"); 76 | assert!(author.is_err()); 77 | 78 | let author = author_parser.parse("Jane Doe; "); 79 | assert!(author.is_err()); 80 | 81 | let author = author_parser 82 | .parse("Jane Doe; jane.doe@example.edu") 83 | .unwrap(); 84 | assert_eq!(author.name, "Jane Doe"); 85 | assert_eq!(author.email, "jane.doe@example.edu"); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use crate::errors::*; 2 | use std::collections::HashMap; 3 | 4 | pub trait Config { 5 | fn get(&self, name: &str) -> Result; 6 | fn get_all(&self, glob: &str) -> Result>; 7 | fn add(&mut self, name: &str, value: &str) -> Result<()>; 8 | fn set(&mut self, name: &str, value: &str) -> Result<()>; 9 | fn clear(&mut self, name: &str) -> Result<()>; 10 | } 11 | -------------------------------------------------------------------------------- /src/errors.rs: -------------------------------------------------------------------------------- 1 | error_chain! {} 2 | -------------------------------------------------------------------------------- /src/git.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::env; 3 | 4 | use crate::config; 5 | use crate::errors::*; 6 | use crate::ConfigScope; 7 | 8 | pub struct Repo { 9 | repo: git2::Repository, 10 | } 11 | 12 | impl Repo { 13 | pub fn new() -> Result { 14 | let repo = env::current_dir() 15 | .chain_err(|| "") 16 | .and_then(|current_dir| git2::Repository::discover(current_dir).chain_err(|| ""))?; 17 | Ok(Repo { repo }) 18 | } 19 | 20 | pub fn config(&self) -> Result { 21 | self.repo 22 | .config() 23 | .map(|config| Config { config }) 24 | .chain_err(|| "") 25 | } 26 | 27 | pub fn auto_include(&self, filename: &str) -> Result<()> { 28 | let include_path = format!("../{}", filename); 29 | 30 | let workdir = match self.repo.workdir() { 31 | Some(dir) => dir, 32 | _ => { 33 | return Ok(()); 34 | } 35 | }; 36 | 37 | let mut path_buf = workdir.to_path_buf(); 38 | path_buf.push(filename); 39 | if !path_buf.exists() { 40 | return Ok(()); 41 | } 42 | 43 | let include_paths = self.include_paths()?; 44 | if include_paths.contains(&include_path) { 45 | return Ok(()); 46 | } 47 | 48 | let mut config = self.local_config()?; 49 | config 50 | .set_multivar("include.path", "^$", &include_path) 51 | .and(Ok(())) 52 | .chain_err(|| "") 53 | } 54 | 55 | fn include_paths(&self) -> Result> { 56 | let config = self.local_config()?; 57 | let mut include_paths: Vec = Vec::new(); 58 | config 59 | .entries(Some("include.path")) 60 | .chain_err(|| "")? 61 | .for_each(|entry| { 62 | let value = entry.value().unwrap_or("").to_string(); 63 | include_paths.push(value) 64 | }) 65 | .chain_err(|| "")?; 66 | Ok(include_paths) 67 | } 68 | 69 | fn local_config(&self) -> Result { 70 | let config = self.repo.config().chain_err(|| "")?; 71 | config.open_level(git2::ConfigLevel::Local).chain_err(|| "") 72 | } 73 | } 74 | 75 | pub struct Config { 76 | config: git2::Config, 77 | } 78 | 79 | impl Config { 80 | pub fn new(scope: ConfigScope) -> Result { 81 | let config = match scope { 82 | ConfigScope::Local => git2::Config::open_default(), 83 | ConfigScope::Global => git2::Config::open_default().and_then(|mut r| r.open_global()), 84 | }; 85 | 86 | config.map(|config| Config { config }).chain_err(|| "") 87 | } 88 | } 89 | 90 | impl config::Config for Config { 91 | fn get(&self, name: &str) -> Result { 92 | self.config 93 | .get_string(name) 94 | .chain_err(|| format!("error getting git config for '{}'", name)) 95 | } 96 | 97 | fn get_all(&self, glob: &str) -> Result> { 98 | let mut result = HashMap::new(); 99 | let entries = self 100 | .config 101 | .entries(Some(glob)) 102 | .chain_err(|| "error getting git config entries")?; 103 | entries 104 | .for_each(|entry| { 105 | if let (Some(name), Some(value)) = (entry.name(), entry.value()) { 106 | result.insert(name.into(), value.into()); 107 | } 108 | }) 109 | .chain_err(|| "")?; 110 | Ok(result) 111 | } 112 | 113 | fn add(&mut self, name: &str, value: &str) -> Result<()> { 114 | self.config 115 | .set_multivar(name, "^$", value) 116 | .chain_err(|| format!("error adding git config '{}': '{}'", name, value)) 117 | } 118 | 119 | fn set(&mut self, name: &str, value: &str) -> Result<()> { 120 | self.config 121 | .set_str(name, value) 122 | .chain_err(|| format!("error setting git config '{}': '{}'", name, value)) 123 | } 124 | 125 | fn clear(&mut self, name: &str) -> Result<()> { 126 | self.config 127 | .remove(name) 128 | .chain_err(|| format!("error removing git config '{}'", name)) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![recursion_limit = "1024"] 2 | #[macro_use] 3 | extern crate error_chain; 4 | extern crate git2; 5 | 6 | pub mod author; 7 | pub mod config; 8 | pub mod errors; 9 | pub mod git; 10 | 11 | use std::collections::HashMap; 12 | use std::env; 13 | use std::process::Command; 14 | 15 | use author::{Author, AuthorParser}; 16 | use config::Config; 17 | use errors::*; 18 | 19 | const NAMESPACE: &str = "git-together"; 20 | const TRIGGERS: [&str; 2] = ["with", "together"]; 21 | 22 | fn namespaced(name: &str) -> String { 23 | format!("{}.{}", NAMESPACE, name) 24 | } 25 | 26 | pub fn run() -> Result { 27 | let all_args: Vec<_> = env::args().skip(1).collect(); 28 | let mut args: Vec<&str> = all_args.iter().map(String::as_ref).collect(); 29 | 30 | let mut gt = if args.contains(&"--global") { 31 | GitTogether::new(ConfigScope::Global) 32 | } else { 33 | GitTogether::new(ConfigScope::Local) 34 | }?; 35 | 36 | args.retain(|&arg| arg != "--global"); 37 | 38 | let mut skip_next = false; 39 | let command = args 40 | .iter() 41 | .find(|x| { 42 | if skip_next { 43 | skip_next = false; 44 | return false; 45 | } 46 | match x { 47 | &&"-c" | &&"--exec-path" | &&"--git-dir" | &&"--work-tree" | &&"--namespace" 48 | | &&"--super-prefix" | &&"--list-cmds" | &&"-C" => { 49 | skip_next = true; 50 | false 51 | } 52 | &&"--version" | &&"--help" => true, 53 | v if v.starts_with('-') => false, 54 | _ => true, 55 | } 56 | }) 57 | .unwrap_or(&""); 58 | 59 | let mut split_args = args.split(|x| x == command); 60 | let global_args = split_args.next().unwrap_or(&[]); 61 | let command_args = split_args.next().unwrap_or(&[]); 62 | 63 | let code = if TRIGGERS.contains(command) { 64 | match command_args { 65 | [] => { 66 | let inits = gt.get_active()?; 67 | let inits: Vec<_> = inits.iter().map(String::as_ref).collect(); 68 | let authors = gt.get_authors(&inits)?; 69 | 70 | for (initials, author) in inits.iter().zip(authors.iter()) { 71 | println!("{}: {}", initials, author); 72 | } 73 | } 74 | ["--list"] => { 75 | let authors = gt.all_authors()?; 76 | let mut sorted: Vec<_> = authors.iter().collect(); 77 | sorted.sort_by(|a, b| a.0.cmp(b.0)); 78 | 79 | for (initials, author) in sorted { 80 | println!("{}: {}", initials, author); 81 | } 82 | } 83 | ["--clear"] => { 84 | gt.clear_active()?; 85 | } 86 | ["--version"] => { 87 | println!( 88 | "{} {}", 89 | option_env!("CARGO_PKG_NAME").unwrap_or("git-together"), 90 | option_env!("CARGO_PKG_VERSION").unwrap_or("unknown version") 91 | ); 92 | } 93 | _ => { 94 | let authors = gt.set_active(command_args)?; 95 | for author in authors { 96 | println!("{}", author); 97 | } 98 | } 99 | } 100 | 101 | 0 102 | } else if gt.is_signoff_cmd(command) { 103 | if command == &"merge" { 104 | env::set_var("GIT_TOGETHER_NO_SIGNOFF", "1"); 105 | } 106 | 107 | let mut cmd = Command::new("git"); 108 | let cmd = cmd.args(global_args); 109 | let cmd = cmd.arg(command); 110 | let cmd = gt.signoff(cmd)?; 111 | let cmd = cmd.args(command_args); 112 | 113 | let status = cmd.status().chain_err(|| "failed to execute process")?; 114 | if status.success() { 115 | gt.rotate_active()?; 116 | } 117 | status.code().ok_or("process terminated by signal")? 118 | } else { 119 | let status = Command::new("git") 120 | .args(args) 121 | .status() 122 | .chain_err(|| "failed to execute process")?; 123 | status.code().ok_or("process terminated by signal")? 124 | }; 125 | 126 | Ok(code) 127 | } 128 | 129 | pub struct GitTogether { 130 | config: C, 131 | author_parser: AuthorParser, 132 | } 133 | 134 | pub enum ConfigScope { 135 | Local, 136 | Global, 137 | } 138 | 139 | impl GitTogether { 140 | pub fn new(scope: ConfigScope) -> Result { 141 | let config = match scope { 142 | ConfigScope::Local => { 143 | let repo = git::Repo::new(); 144 | if let Ok(ref repo) = repo { 145 | let _ = repo.auto_include(&format!(".{}", NAMESPACE)); 146 | }; 147 | 148 | repo.and_then(|r| r.config()) 149 | .or_else(|_| git::Config::new(scope))? 150 | } 151 | ConfigScope::Global => git::Config::new(scope)?, 152 | }; 153 | 154 | let domain = config.get(&namespaced("domain")).ok(); 155 | let author_parser = AuthorParser { domain }; 156 | 157 | Ok(GitTogether { 158 | config, 159 | author_parser, 160 | }) 161 | } 162 | } 163 | 164 | impl GitTogether { 165 | pub fn set_active(&mut self, inits: &[&str]) -> Result> { 166 | let authors = self.get_authors(inits)?; 167 | self.config.set(&namespaced("active"), &inits.join("+"))?; 168 | 169 | self.save_original_user()?; 170 | if let Some(author) = authors.get(0) { 171 | self.set_user(&author.name, &author.email)?; 172 | } 173 | 174 | Ok(authors) 175 | } 176 | 177 | pub fn clear_active(&mut self) -> Result<()> { 178 | self.config.clear(&namespaced("active"))?; 179 | 180 | let _ = self.config.clear("user.name"); 181 | let _ = self.config.clear("user.email"); 182 | 183 | Ok(()) 184 | } 185 | 186 | fn save_original_user(&mut self) -> Result<()> { 187 | if let Ok(name) = self.config.get("user.name") { 188 | let key = namespaced("user.name"); 189 | self.config 190 | .get(&key) 191 | .map(|_| ()) 192 | .or_else(|_| self.config.set(&key, &name))?; 193 | } 194 | 195 | if let Ok(email) = self.config.get("user.email") { 196 | let key = namespaced("user.email"); 197 | self.config 198 | .get(&key) 199 | .map(|_| ()) 200 | .or_else(|_| self.config.set(&key, &email))?; 201 | } 202 | 203 | Ok(()) 204 | } 205 | 206 | fn set_user(&mut self, name: &str, email: &str) -> Result<()> { 207 | self.config.set("user.name", name)?; 208 | self.config.set("user.email", email)?; 209 | 210 | Ok(()) 211 | } 212 | 213 | pub fn all_authors(&self) -> Result> { 214 | let mut authors = HashMap::new(); 215 | let raw = self.config.get_all(&namespaced("authors."))?; 216 | for (name, value) in raw { 217 | let initials = name.split('.').last().ok_or("")?; 218 | let author = self.parse_author(initials, &value)?; 219 | authors.insert(initials.into(), author); 220 | } 221 | Ok(authors) 222 | } 223 | 224 | pub fn is_signoff_cmd(&self, cmd: &str) -> bool { 225 | let signoffs = ["commit", "merge", "revert"]; 226 | signoffs.contains(&cmd) || self.is_signoff_alias(cmd) 227 | } 228 | 229 | fn is_signoff_alias(&self, cmd: &str) -> bool { 230 | self.config 231 | .get(&namespaced("aliases")) 232 | .unwrap_or_else(|_| String::new()) 233 | .split(',') 234 | .filter(|s| s != &"") 235 | .any(|a| a == cmd) 236 | } 237 | 238 | pub fn signoff<'a>(&self, cmd: &'a mut Command) -> Result<&'a mut Command> { 239 | let active = self.config.get(&namespaced("active"))?; 240 | let inits: Vec<_> = active.split('+').collect(); 241 | let authors = self.get_authors(&inits)?; 242 | 243 | let (author, committer) = match *authors.as_slice() { 244 | [] => { 245 | return Err("".into()); 246 | } 247 | [ref solo] => (solo, solo), 248 | [ref author, ref committer, ..] => (author, committer), 249 | }; 250 | 251 | let cmd = cmd 252 | .env("GIT_AUTHOR_NAME", author.name.clone()) 253 | .env("GIT_AUTHOR_EMAIL", author.email.clone()) 254 | .env("GIT_COMMITTER_NAME", committer.name.clone()) 255 | .env("GIT_COMMITTER_EMAIL", committer.email.clone()); 256 | 257 | let no_signoff = env::var("GIT_TOGETHER_NO_SIGNOFF").is_ok(); 258 | Ok(if !no_signoff && author != committer { 259 | cmd.arg("--signoff") 260 | } else { 261 | cmd 262 | }) 263 | } 264 | 265 | fn get_active(&self) -> Result> { 266 | self.config 267 | .get(&namespaced("active")) 268 | .map(|active| active.split('+').map(|s| s.into()).collect()) 269 | } 270 | 271 | pub fn rotate_active(&mut self) -> Result<()> { 272 | self.get_active().and_then(|active| { 273 | let mut inits: Vec<_> = active.iter().map(String::as_ref).collect(); 274 | if !inits.is_empty() { 275 | let author = inits.remove(0); 276 | inits.push(author); 277 | } 278 | self.set_active(&inits[..]).map(|_| ()) 279 | }) 280 | } 281 | 282 | fn get_authors(&self, inits: &[&str]) -> Result> { 283 | inits 284 | .iter() 285 | .map(|&initials| self.get_author(initials)) 286 | .collect() 287 | } 288 | 289 | fn get_author(&self, initials: &str) -> Result { 290 | self.config 291 | .get(&namespaced(&format!("authors.{}", initials))) 292 | .chain_err(|| format!("author not found for '{}'", initials)) 293 | .and_then(|raw| self.parse_author(initials, &raw)) 294 | } 295 | 296 | fn parse_author(&self, initials: &str, raw: &str) -> Result { 297 | self.author_parser 298 | .parse(raw) 299 | .chain_err(|| format!("invalid author for '{}': '{}'", initials, raw)) 300 | } 301 | } 302 | 303 | #[cfg(test)] 304 | mod tests { 305 | use super::*; 306 | 307 | use std::collections::HashMap; 308 | use std::ops::Index; 309 | 310 | use author::{Author, AuthorParser}; 311 | use config::Config; 312 | 313 | #[test] 314 | fn get_authors() { 315 | let config = MockConfig::new(&[ 316 | ("git-together.authors.jh", ""), 317 | ("git-together.authors.nn", "Naomi Nagata"), 318 | ("git-together.authors.ab", "Amos Burton; aburton"), 319 | ("git-together.authors.ak", "Alex Kamal; akamal"), 320 | ("git-together.authors.ca", "Chrisjen Avasarala;"), 321 | ("git-together.authors.bd", "Bobbie Draper; bdraper@mars.mil"), 322 | ( 323 | "git-together.authors.jm", 324 | "Joe Miller; jmiller@starhelix.com", 325 | ), 326 | ]); 327 | let author_parser = AuthorParser { 328 | domain: Some("rocinante.com".into()), 329 | }; 330 | let gt = GitTogether { 331 | config, 332 | author_parser, 333 | }; 334 | 335 | assert!(gt.get_authors(&["jh"]).is_err()); 336 | assert!(gt.get_authors(&["nn"]).is_err()); 337 | assert!(gt.get_authors(&["ca"]).is_err()); 338 | assert!(gt.get_authors(&["jh", "bd"]).is_err()); 339 | 340 | assert_eq!( 341 | gt.get_authors(&["ab", "ak"]).unwrap(), 342 | vec![ 343 | Author { 344 | name: "Amos Burton".into(), 345 | email: "aburton@rocinante.com".into(), 346 | }, 347 | Author { 348 | name: "Alex Kamal".into(), 349 | email: "akamal@rocinante.com".into(), 350 | } 351 | ] 352 | ); 353 | assert_eq!( 354 | gt.get_authors(&["ab", "bd", "jm"]).unwrap(), 355 | vec![ 356 | Author { 357 | name: "Amos Burton".into(), 358 | email: "aburton@rocinante.com".into(), 359 | }, 360 | Author { 361 | name: "Bobbie Draper".into(), 362 | email: "bdraper@mars.mil".into(), 363 | }, 364 | Author { 365 | name: "Joe Miller".into(), 366 | email: "jmiller@starhelix.com".into(), 367 | } 368 | ] 369 | ); 370 | } 371 | 372 | #[test] 373 | fn set_active_solo() { 374 | let config = MockConfig::new(&[ 375 | ("git-together.authors.jh", "James Holden; jholden"), 376 | ("git-together.authors.nn", "Naomi Nagata; nnagata"), 377 | ("user.name", "Bobbie Draper"), 378 | ("user.email", "bdraper@mars.mil"), 379 | ]); 380 | let author_parser = AuthorParser { 381 | domain: Some("rocinante.com".into()), 382 | }; 383 | let mut gt = GitTogether { 384 | config, 385 | author_parser, 386 | }; 387 | 388 | gt.set_active(&["jh"]).unwrap(); 389 | assert_eq!(gt.get_active().unwrap(), vec!["jh"]); 390 | assert_eq!(gt.config["user.name"], "James Holden"); 391 | assert_eq!(gt.config["user.email"], "jholden@rocinante.com"); 392 | assert_eq!(gt.config["git-together.user.name"], "Bobbie Draper"); 393 | assert_eq!(gt.config["git-together.user.email"], "bdraper@mars.mil"); 394 | } 395 | 396 | #[test] 397 | fn set_active_pair() { 398 | let config = MockConfig::new(&[ 399 | ("git-together.authors.jh", "James Holden; jholden"), 400 | ("git-together.authors.nn", "Naomi Nagata; nnagata"), 401 | ("user.name", "Bobbie Draper"), 402 | ("user.email", "bdraper@mars.mil"), 403 | ]); 404 | let author_parser = AuthorParser { 405 | domain: Some("rocinante.com".into()), 406 | }; 407 | let mut gt = GitTogether { 408 | config, 409 | author_parser, 410 | }; 411 | 412 | gt.set_active(&["nn", "jh"]).unwrap(); 413 | assert_eq!(gt.get_active().unwrap(), vec!["nn", "jh"]); 414 | assert_eq!(gt.config["user.name"], "Naomi Nagata"); 415 | assert_eq!(gt.config["user.email"], "nnagata@rocinante.com"); 416 | assert_eq!(gt.config["git-together.user.name"], "Bobbie Draper"); 417 | assert_eq!(gt.config["git-together.user.email"], "bdraper@mars.mil"); 418 | } 419 | 420 | #[test] 421 | fn clear_active_pair() { 422 | let config = MockConfig::new(&[ 423 | ("git-together.authors.jh", "James Holden; jholden"), 424 | ("git-together.authors.nn", "Naomi Nagata; nnagata"), 425 | ("user.name", "Bobbie Draper"), 426 | ("user.email", "bdraper@mars.mil"), 427 | ]); 428 | let author_parser = AuthorParser { 429 | domain: Some("rocinante.com".into()), 430 | }; 431 | let mut gt = GitTogether { 432 | config, 433 | author_parser, 434 | }; 435 | 436 | gt.set_active(&["nn", "jh"]).unwrap(); 437 | gt.clear_active().unwrap(); 438 | assert!(gt.get_active().is_err()); 439 | assert!(gt.config.get("user.name").is_err()); 440 | assert!(gt.config.get("user.email").is_err()); 441 | } 442 | 443 | #[test] 444 | fn multiple_set_active() { 445 | let config = MockConfig::new(&[ 446 | ("git-together.authors.jh", "James Holden; jholden"), 447 | ("git-together.authors.nn", "Naomi Nagata; nnagata"), 448 | ("user.name", "Bobbie Draper"), 449 | ("user.email", "bdraper@mars.mil"), 450 | ]); 451 | let author_parser = AuthorParser { 452 | domain: Some("rocinante.com".into()), 453 | }; 454 | let mut gt = GitTogether { 455 | config, 456 | author_parser, 457 | }; 458 | 459 | gt.set_active(&["nn"]).unwrap(); 460 | gt.set_active(&["jh"]).unwrap(); 461 | assert_eq!(gt.config["git-together.user.name"], "Bobbie Draper"); 462 | assert_eq!(gt.config["git-together.user.email"], "bdraper@mars.mil"); 463 | } 464 | 465 | #[test] 466 | fn rotate_active() { 467 | let config = MockConfig::new(&[ 468 | ("git-together.active", "jh+nn"), 469 | ("git-together.authors.jh", "James Holden; jholden"), 470 | ("git-together.authors.nn", "Naomi Nagata; nnagata"), 471 | ]); 472 | let author_parser = AuthorParser { 473 | domain: Some("rocinante.com".into()), 474 | }; 475 | let mut gt = GitTogether { 476 | config, 477 | author_parser, 478 | }; 479 | 480 | gt.rotate_active().unwrap(); 481 | assert_eq!(gt.get_active().unwrap(), vec!["nn", "jh"]); 482 | } 483 | 484 | #[test] 485 | fn all_authors() { 486 | let config = MockConfig::new(&[ 487 | ("git-together.active", "jh+nn"), 488 | ("git-together.authors.ab", "Amos Burton; aburton"), 489 | ("git-together.authors.bd", "Bobbie Draper; bdraper@mars.mil"), 490 | ( 491 | "git-together.authors.jm", 492 | "Joe Miller; jmiller@starhelix.com", 493 | ), 494 | ]); 495 | let author_parser = AuthorParser { 496 | domain: Some("rocinante.com".into()), 497 | }; 498 | let gt = GitTogether { 499 | config, 500 | author_parser, 501 | }; 502 | 503 | let all_authors = gt.all_authors().unwrap(); 504 | assert_eq!(all_authors.len(), 3); 505 | assert_eq!( 506 | all_authors["ab"], 507 | Author { 508 | name: "Amos Burton".into(), 509 | email: "aburton@rocinante.com".into(), 510 | } 511 | ); 512 | assert_eq!( 513 | all_authors["bd"], 514 | Author { 515 | name: "Bobbie Draper".into(), 516 | email: "bdraper@mars.mil".into(), 517 | } 518 | ); 519 | assert_eq!( 520 | all_authors["jm"], 521 | Author { 522 | name: "Joe Miller".into(), 523 | email: "jmiller@starhelix.com".into(), 524 | } 525 | ); 526 | } 527 | 528 | #[test] 529 | fn is_signoff_cmd_basics() { 530 | let config = MockConfig::new(&[]); 531 | let author_parser = AuthorParser { 532 | domain: Some("rocinante.com".into()), 533 | }; 534 | let gt = GitTogether { 535 | config, 536 | author_parser, 537 | }; 538 | 539 | assert_eq!(gt.is_signoff_cmd("commit"), true); 540 | assert_eq!(gt.is_signoff_cmd("merge"), true); 541 | assert_eq!(gt.is_signoff_cmd("revert"), true); 542 | assert_eq!(gt.is_signoff_cmd("bisect"), false); 543 | } 544 | 545 | #[test] 546 | fn is_signoff_cmd_aliases() { 547 | let config = MockConfig::new(&[("git-together.aliases", "ci,m,rv")]); 548 | let author_parser = AuthorParser { 549 | domain: Some("rocinante.com".into()), 550 | }; 551 | let gt = GitTogether { 552 | config, 553 | author_parser, 554 | }; 555 | 556 | assert_eq!(gt.is_signoff_cmd("ci"), true); 557 | assert_eq!(gt.is_signoff_cmd("m"), true); 558 | assert_eq!(gt.is_signoff_cmd("rv"), true); 559 | } 560 | 561 | struct MockConfig { 562 | data: HashMap, 563 | } 564 | 565 | impl MockConfig { 566 | fn new(data: &[(&str, &str)]) -> MockConfig { 567 | MockConfig { 568 | data: data.iter().map(|&(k, v)| (k.into(), v.into())).collect(), 569 | } 570 | } 571 | } 572 | 573 | impl<'a> Index<&'a str> for MockConfig { 574 | type Output = String; 575 | 576 | fn index(&self, key: &'a str) -> &String { 577 | self.data.index(key) 578 | } 579 | } 580 | 581 | impl Config for MockConfig { 582 | fn get(&self, name: &str) -> Result { 583 | self.data 584 | .get(name.into()) 585 | .cloned() 586 | .ok_or(format!("name not found: '{}'", name).into()) 587 | } 588 | 589 | fn get_all(&self, glob: &str) -> Result> { 590 | Ok(self 591 | .data 592 | .iter() 593 | .filter(|&(name, _)| name.contains(glob)) 594 | .map(|(name, value)| (name.clone(), value.clone())) 595 | .collect()) 596 | } 597 | 598 | fn add(&mut self, _: &str, _: &str) -> Result<()> { 599 | unimplemented!(); 600 | } 601 | 602 | fn set(&mut self, name: &str, value: &str) -> Result<()> { 603 | self.data.insert(name.into(), value.into()); 604 | Ok(()) 605 | } 606 | 607 | fn clear(&mut self, name: &str) -> Result<()> { 608 | self.data.remove(name.into()); 609 | Ok(()) 610 | } 611 | } 612 | } 613 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | extern crate error_chain; 3 | extern crate git_together; 4 | 5 | quick_main!(git_together::run); 6 | --------------------------------------------------------------------------------