├── .github └── workflows │ ├── push.yml │ ├── release-build.yml │ ├── release-verify-deb-ubuntu.yml │ ├── release-verify-rpm-rhel.yml │ └── release.yml ├── .gitignore ├── .quickhook ├── commit-msg │ └── trailing-whitespace └── pre-commit │ └── go-vet ├── LICENSE ├── README.md ├── codecov.yml ├── go.mod ├── go.sum ├── hooks ├── commit_msg.go ├── commit_msg_test.go ├── executable.go ├── pre_commit.go ├── pre_commit_git_shim.sh └── pre_commit_test.go ├── install.go ├── install_test.go ├── internal ├── fanout.go └── test │ └── tempdir.go ├── quickhook.go ├── repo ├── git.go └── repo.go ├── scripts ├── coverage.sh └── install.sh └── tracing ├── tracing.go └── tracing_test.go /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: push 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | test-cover-integration: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-go@v3 15 | with: 16 | go-version-file: 'go.mod' 17 | - name: Build and test with integration coverage 18 | run: | 19 | go build -cover -v 20 | mkdir -p coverage 21 | GOCOVERDIR="$(pwd)/coverage" go test ./... -v -count=1 22 | - uses: actions/upload-artifact@v4 23 | with: 24 | name: coverage-integration 25 | path: coverage 26 | test-cover-build: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - uses: actions/checkout@v4 30 | - uses: actions/setup-go@v3 31 | with: 32 | go-version-file: 'go.mod' 33 | - name: Build and test with unit coverage 34 | run: | 35 | go build -v 36 | mkdir -p coverage 37 | go test ./... -v -count=1 -cover -args -test.gocoverdir="$PWD/coverage" 38 | - uses: actions/upload-artifact@v4 39 | with: 40 | name: coverage-unit 41 | path: coverage 42 | coverage: 43 | runs-on: ubuntu-latest 44 | needs: [test-cover-integration, test-cover-build] 45 | steps: 46 | - uses: actions/checkout@v4 47 | - uses: actions/download-artifact@v4 48 | - name: Process coverage 49 | run: | 50 | go tool covdata textfmt -i=./coverage-integration,./coverage-unit -o=coverage.txt 51 | - name: Upload coverage reports to Codecov 52 | uses: codecov/codecov-action@v4 53 | with: 54 | file: ./coverage.txt 55 | token: ${{ secrets.CODECOV_TOKEN }} 56 | -------------------------------------------------------------------------------- /.github/workflows/release-build.yml: -------------------------------------------------------------------------------- 1 | name: release-build 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | version: 7 | description: "Version without the leading 'v'" 8 | type: string 9 | required: true 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | os: [linux] 17 | arch: [amd64, arm64] 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: actions/setup-go@v3 21 | with: 22 | go-version-file: 'go.mod' 23 | - uses: ruby/setup-ruby@v1.146.0 24 | with: 25 | ruby-version: '3.1' 26 | - run: gem install fpm 27 | - name: Set version 28 | run: sed -i "s/VERSION = \"[^\"]*\"/VERSION = \"${{ inputs.version }}\"/" quickhook.go 29 | - name: Build 30 | run: | 31 | mkdir -p build/usr/bin 32 | GOARCH=${{ matrix.arch }} GOOS=${{ matrix.os }} CGO_ENABLED=0 go build -o build/usr/bin/quickhook 33 | - name: Package .debs 34 | if: ${{ matrix.os == 'linux' }} 35 | run: | 36 | fpm \ 37 | --input-type dir \ 38 | --output-type deb \ 39 | --package quickhook-${{ inputs.version }}-${{ matrix.os }}-${{ matrix.arch }}.deb \ 40 | --name quickhook \ 41 | --license bsd-3-clause \ 42 | --version ${{ inputs.version }} \ 43 | --architecture ${{ matrix.arch }} \ 44 | --chdir build . 45 | - name: Package .rpms 46 | if: ${{ matrix.os == 'linux' }} 47 | run: | 48 | fpm \ 49 | --input-type dir \ 50 | --output-type rpm \ 51 | --package quickhook-${{ inputs.version }}-${{ matrix.os }}-${{ matrix.arch }}.rpm \ 52 | --name quickhook \ 53 | --license bsd-3-clause \ 54 | --version ${{ inputs.version }} \ 55 | --architecture ${{ matrix.arch }} \ 56 | --chdir build . 57 | # - name: Copy binaries 58 | # if: ${{ matrix.os == 'darwin' }} 59 | # run: | 60 | # cp build/usr/bin/quickhook quickhook-${{ matrix.os }}-${{ matrix.arch }} 61 | - uses: actions/upload-artifact@v3 62 | with: 63 | path: | 64 | *.deb 65 | *.rpm 66 | -------------------------------------------------------------------------------- /.github/workflows/release-verify-deb-ubuntu.yml: -------------------------------------------------------------------------------- 1 | name: release-verify-deb-ubuntu 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | version: 7 | description: "Version without the leading 'v'" 8 | type: string 9 | required: true 10 | 11 | jobs: 12 | verify: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/download-artifact@v4 16 | with: 17 | name: artifact 18 | - run: | 19 | dpkg --info quickhook-${{ inputs.version }}-linux-amd64.deb 20 | dpkg --contents quickhook-${{ inputs.version }}-linux-amd64.deb 21 | sudo apt install ./quickhook-${{ inputs.version }}-linux-amd64.deb 22 | - run: | 23 | quickhook --version 24 | [[ $(quickhook --version) == "${{ inputs.version }}" ]] 25 | -------------------------------------------------------------------------------- /.github/workflows/release-verify-rpm-rhel.yml: -------------------------------------------------------------------------------- 1 | name: release-verify-rpm-rhel 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | version: 7 | description: "Version without the leading 'v'" 8 | type: string 9 | required: true 10 | 11 | jobs: 12 | verify: 13 | runs-on: ubuntu-latest 14 | container: redhat/ubi8-minimal:latest 15 | steps: 16 | - uses: actions/download-artifact@v4 17 | with: 18 | name: artifact 19 | - run: | 20 | rpm --package quickhook-${{ inputs.version }}-linux-amd64.rpm --query --info 21 | rpm --package quickhook-${{ inputs.version }}-linux-amd64.rpm --query --list 22 | rpm --install quickhook-${{ inputs.version }}-linux-amd64.rpm 23 | - run: | 24 | quickhook --version 25 | [[ $(quickhook --version) == "${{ inputs.version }}" ]] 26 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | run-name: Prepare ${{ inputs.version }} for release 3 | 4 | on: 5 | workflow_dispatch: 6 | inputs: 7 | version: 8 | description: 'Version (eg. v1.2.3)' 9 | type: string 10 | required: true 11 | 12 | jobs: 13 | version: 14 | runs-on: ubuntu-latest 15 | outputs: 16 | version: ${{ steps.version.outputs.version }} 17 | steps: 18 | # It's easier to work with the version when we've stripped the leading 19 | # "v" off of it. 20 | - id: version 21 | run: | 22 | echo "${{ inputs.version }}" | sed -E "s/^v?/version=/" >> $GITHUB_OUTPUT 23 | build-linux: 24 | uses: ./.github/workflows/release-build.yml 25 | needs: version 26 | with: 27 | version: ${{ needs.version.outputs.version }} 28 | verify-deb-ubuntu: 29 | uses: ./.github/workflows/release-verify-deb-ubuntu.yml 30 | needs: [version, build-linux] 31 | with: 32 | version: ${{ needs.version.outputs.version }} 33 | verify-rpm-rhel: 34 | uses: ./.github/workflows/release-verify-rpm-rhel.yml 35 | needs: [version, build-linux] 36 | with: 37 | version: ${{ needs.version.outputs.version }} 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # Executable itself when building locally. 4 | quickhook 5 | 6 | # Code coverage artifacts. 7 | coverage 8 | -------------------------------------------------------------------------------- /.quickhook/commit-msg/trailing-whitespace: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | grep '[[:blank:]]$' $1 > /dev/null 4 | 5 | # Exit with an error if it matched lines with trailing space. 6 | if [ $? -eq 0 ]; then 7 | echo "Commit message has trailing whitespace." 8 | exit 1 9 | fi 10 | -------------------------------------------------------------------------------- /.quickhook/pre-commit/go-vet: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | go vet ./... 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016-2023, Dirk Gadsden 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 3. Neither the name of the copyright holder nor the names of its contributors 13 | may be used to endorse or promote products derived from this software 14 | without specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 17 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 18 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # quickhook 2 | 3 | ![Build Status](https://github.com/dirk/quickhook/actions/workflows/push.yml/badge.svg) 4 | [![codecov](https://codecov.io/github/dirk/quickhook/branch/main/graph/badge.svg?token=FRMS9TRJ93)](https://app.codecov.io/github/dirk/quickhook) 5 | 6 | Quickhook is a Git hook runner designed for speed. It is opinionated where it matters: hooks are executables organized by directory and must exit with a non-zero code on error. Everything else is up to you! 7 | 8 | ## Installation 9 | 10 | ### `go install` 11 | 12 | If you have your $PATH set up for Go then it's as simple as: 13 | 14 | ```sh 15 | $ go install github.com/dirk/quickhook 16 | $ quickhook --version 17 | 1.5.0 18 | ``` 19 | 20 | To uninstall use `clean -i`: 21 | 22 | ```sh 23 | $ go clean -i github.com/dirk/quickhook 24 | ``` 25 | 26 | ### Homebrew 27 | 28 | If you're on Mac there is a [Homebrew tap for Quickhook](https://github.com/dirk/homebrew-quickhook): 29 | 30 | ```sh 31 | $ brew tap dirk/quickhook 32 | ==> Tapping dirk/quickhook 33 | ... 34 | Tapped 1 formula (14 files, 12.6KB). 35 | 36 | $ brew install quickhook 37 | ==> Fetching dirk/quickhook/quickhook 38 | ==> Downloading https://github.com/dirk/quickhook/archive/v1.5.0.tar.gz 39 | ... 40 | /opt/homebrew/Cellar/quickhook/1.5.0: 5 files, 3.1MB, built in 2 seconds 41 | ``` 42 | 43 | ### Linux 44 | 45 | Installable debs and RPMs are available for the [latest release](https://github.com/dirk/quickhook/releases/latest). 46 | 47 | ```sh 48 | # Installing a .deb 49 | wget https://github.com/dirk/quickhook/releases/download/v1.5.0/quickhook-1.5.0-amd64.deb 50 | sudo apt install ./quickhook-1.5.0-amd64.deb 51 | 52 | # Installing a .rpm 53 | wget https://github.com/dirk/quickhook/releases/download/v1.5.0/quickhook-1.5.0-amd64.rpm 54 | sudo rpm --install quickhook-1.5.0-amd64.rpm 55 | ``` 56 | 57 | ## Usage 58 | 59 | First you'll need to install Quickhook in your repository: `quickhook install` command will discover hooks defined in the `.quickhook` directory and create Git hook shims for those. For example, the below is what you can expect from running installation in this repository: 60 | 61 | ```sh 62 | $ quickhook install 63 | Create file .git/hooks/commit-msg? [yn] y 64 | Installed shim .git/hooks/commit-msg 65 | Create file .git/hooks/pre-commit? [yn] y 66 | Installed shim .git/hooks/pre-commit 67 | ``` 68 | 69 | Quickhook provides some options to run various hooks directly for development and testing. This way you don't have to follow the whole Git commit workflow just to exercise the new hook you're working on. 70 | 71 | ```sh 72 | # Run the pre-commit hooks on all Git-tracked files in the repository 73 | $ quickhook hook pre-commit --all 74 | 75 | # Run them on just one or more files 76 | $ quickhook hook pre-commit --files=hooks/commit_msg.go,hooks/pre_commit.go 77 | ``` 78 | 79 | You can see all of the options by passing `--help` to the sub-command: 80 | 81 | ```sh 82 | $ quickhook hook pre-commit --help 83 | ... 84 | OPTIONS: 85 | --all, -a Run on all Git-tracked files 86 | --files, -F Run on the given comma-separated list of files 87 | ``` 88 | 89 | ## Writing hooks 90 | 91 | Quickhook will look for hooks in a corresponding sub-directory of the `.quickhook` directory in your repository. For example, it will look for pre-commit hooks in `.quickhook/pre-commit/`. A hook is any executable file in that directory. 92 | 93 | ### pre-commit 94 | 95 | Pre-commit hooks receive the list of staged files separated by newlines on stdin. They are expected to write their result to stdout/stderr (Quickhook doesn't care). If they exit with a non-zero exit code then the commit will be aborted and their output displayed to the user. See the [`go-vet`](.quickhook/pre-commit/go-vet) file for an example. 96 | 97 | **Note**: Pre-commit hooks will be executed in parallel and should not mutate the local repository state. For this reason `git` is shimmed on the hooks' $PATH to be unavailable for all but the safest commands. The shim is implemented [here](./hooks/pre_commit_git_shim.sh). 98 | 99 | #### Mutating hooks 100 | 101 | You can also add executables to `.quickhook/pre-commit-mutating/`. These will be run _sequentially_, without Git shimmed, and may mutate the local repository state. 102 | 103 | #### Suggested formatting 104 | 105 | If you're unsure how to format your lines, there's an informal Unix convention which is already followed by many programming languages, linters, and so forth. 106 | 107 | ``` 108 | some/directory/and/file.go:123: Something doesn't look right 109 | ``` 110 | 111 | A more formal definition of an error line is: 112 | 113 | - Sequence of characters representing a valid path 114 | - A colon (`:`) character 115 | - Integer of the line where the error occurred 116 | - A colon character followed by a space character 117 | - Any printable character describing the error 118 | - A newline (`\n`) terminating the error line 119 | 120 | ### commit-msg 121 | 122 | Commit-message hooks are run sequentially. They receive a single argument: a path to a temporary file containing the message for the commit. If they exit with a non-zero exit code the commit will be aborted and any stdout/stderr output displayed to the user. 123 | 124 | Given that they are run sequentially, `commit-msg` hooks are allowed to mutate the commit message temporary file. 125 | 126 | ## Performance 127 | 128 | Quickhook is designed to be as fast and lightweight as possible. There are a few guiding principles for this: 129 | 130 | - Ship as a small, self-contained executable. 131 | - No configuration. 132 | - Do as much as possible in parallel. 133 | 134 | ### Tracing 135 | 136 | Set `QUICKHOOK_TRACE=1` (or pass `--trace`) to enable tracing during hook execution: 137 | 138 | ```sh 139 | $ QUICKHOOK_TRACE=1 git commit ... 140 | Traced 3 span(s): 141 | git diff 4ms 142 | git shim 473ns 143 | hook pre-commit go-vet 238ms 144 | Traced 1 span(s): 145 | hook commit-msg trailing-whitespace 3ms 146 | ... 147 | ``` 148 | 149 | ## Contributing 150 | 151 | Contributions are welcome. If you want to use the locally-built version of Quickhook in the Git hooks, there's a simple 3-line script that will set that up: 152 | 153 | ```sh 154 | $ ./scripts/install.sh 155 | Installed shim .git/hooks/commit-msg 156 | Installed shim .git/hooks/pre-commit 157 | ``` 158 | 159 | Building and testing should be straightforward: 160 | 161 | ```sh 162 | # Build a quickhook executable: 163 | $ go build 164 | 165 | # Run all tests: 166 | $ go test ./... 167 | ``` 168 | 169 | **Warning**: Many of the tests are integration-style tests which depend on a locally-built Quickhook executable. If you see unexpected test failures, please first try running `go build` before you rerun tests. 170 | 171 | There's also a script that will generate and open an HTML page with coverage: 172 | 173 | ```sh 174 | $ ./scripts/coverage.sh 175 | ``` 176 | 177 | ## License 178 | 179 | Released under the Modified BSD license, see [LICENSE](LICENSE) for details. 180 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: false 2 | github_checks: 3 | annotations: false 4 | coverage: 5 | status: 6 | project: 7 | default: 8 | informational: true 9 | patch: 10 | default: 11 | informational: true 12 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/dirk/quickhook 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/alecthomas/kong v0.9.0 7 | github.com/creack/pty v1.1.21 8 | github.com/fatih/color v1.16.0 9 | github.com/samber/lo v1.39.0 10 | github.com/stretchr/testify v1.9.0 11 | ) 12 | 13 | require ( 14 | github.com/alecthomas/assert/v2 v2.7.0 // indirect 15 | github.com/davecgh/go-spew v1.1.1 // indirect 16 | github.com/kr/pretty v0.3.1 // indirect 17 | github.com/mattn/go-colorable v0.1.13 // indirect 18 | github.com/mattn/go-isatty v0.0.20 // indirect 19 | github.com/pmezard/go-difflib v1.0.0 // indirect 20 | github.com/rogpeppe/go-internal v1.12.0 // indirect 21 | golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect 22 | golang.org/x/sys v0.18.0 // indirect 23 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 24 | gopkg.in/yaml.v3 v3.0.1 // indirect 25 | ) 26 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= 2 | github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 3 | github.com/alecthomas/kong v0.9.0 h1:G5diXxc85KvoV2f0ZRVuMsi45IrBgx9zDNGNj165aPA= 4 | github.com/alecthomas/kong v0.9.0/go.mod h1:Y47y5gKfHp1hDc7CH7OeXgLIpp+Q2m1Ni0L5s3bI8Os= 5 | github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= 6 | github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 7 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 8 | github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= 9 | github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 10 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 11 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 13 | github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 14 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 15 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 16 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 17 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 18 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 19 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 20 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 21 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 22 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 23 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 24 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 25 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 26 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 27 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 28 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 29 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 30 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 31 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 32 | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= 33 | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= 34 | github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA= 35 | github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= 36 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 37 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 38 | golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= 39 | golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ= 40 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 41 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 42 | golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= 43 | golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 44 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 45 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 46 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 47 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 48 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 49 | -------------------------------------------------------------------------------- /hooks/commit_msg.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/dirk/quickhook/repo" 7 | ) 8 | 9 | const COMMIT_MSG_HOOK = "commit-msg" 10 | 11 | type CommitMsg struct { 12 | Repo *repo.Repo 13 | } 14 | 15 | func (hook *CommitMsg) Run(messageFile string) error { 16 | executables, err := hook.Repo.FindHookExecutables(COMMIT_MSG_HOOK) 17 | if err != nil { 18 | return err 19 | } 20 | for _, executable := range executables { 21 | result := runExecutable(hook.Repo.Root, executable, []string{}, "", messageFile) 22 | if result.err == nil { 23 | continue 24 | } 25 | result.printStderr() 26 | result.printStdout() 27 | os.Exit(FAILED_EXIT_CODE) 28 | } 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /hooks/commit_msg_test.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | 11 | "github.com/dirk/quickhook/internal/test" 12 | ) 13 | 14 | func initGitForCommitMsg(t *testing.T) test.TempDir { 15 | tempDir := test.NewTempDir(t, 1) 16 | tempDir.RequireExec("git", "init", "--quiet", ".") 17 | return tempDir 18 | } 19 | 20 | func writeCommitEditMsg(t *testing.T, data string) string { 21 | name := path.Join(t.TempDir(), "COMMIT_EDITMSG") 22 | err := os.WriteFile(name, []byte(data), 0644) 23 | require.NoError(t, err) 24 | return name 25 | } 26 | 27 | func TestHookMutatesCommitMsg(t *testing.T) { 28 | tempDir := initGitForCommitMsg(t) 29 | tempDir.MkdirAll(".quickhook", "commit-msg") 30 | tempDir.WriteFile( 31 | []string{".quickhook", "commit-msg", "appends"}, 32 | // -n makes echo not emit a trailing newline. 33 | "#!/bin/bash \n echo -n \" second\" >> $1") 34 | 35 | editMsgFile := writeCommitEditMsg(t, "First") 36 | _, err := tempDir.ExecQuickhook("hook", "commit-msg", editMsgFile) 37 | assert.NoError(t, err) 38 | 39 | newEditMsg, err := os.ReadFile(editMsgFile) 40 | assert.NoError(t, err) 41 | assert.Equal(t, "First second", string(newEditMsg)) 42 | } 43 | 44 | func TestFailingHook(t *testing.T) { 45 | tempDir := initGitForCommitMsg(t) 46 | tempDir.MkdirAll(".quickhook", "commit-msg") 47 | tempDir.WriteFile( 48 | []string{".quickhook", "commit-msg", "fails"}, 49 | "#!/bin/bash \n echo \"failed\" \n exit 1") 50 | 51 | output, err := tempDir.ExecQuickhook("hook", "commit-msg", writeCommitEditMsg(t, "Test")) 52 | assert.Error(t, err) 53 | assert.Equal(t, "fails: failed\n", output) 54 | } 55 | -------------------------------------------------------------------------------- /hooks/executable.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "path" 9 | "strings" 10 | 11 | "github.com/fatih/color" 12 | 13 | "github.com/dirk/quickhook/tracing" 14 | ) 15 | 16 | func runExecutable(root, executable string, env []string, stdin string, arg ...string) hookResult { 17 | dir, command := path.Split(executable) 18 | span := tracing.NewSpan(fmt.Sprintf("hook %s %s", path.Base(dir), command)) 19 | defer span.End() 20 | cmd := exec.Command(path.Join(root, executable), arg...) 21 | cmd.Env = append(os.Environ(), env...) 22 | cmd.Stdin = strings.NewReader(stdin) 23 | var stderr bytes.Buffer 24 | cmd.Stderr = &stderr 25 | stdout, err := cmd.Output() 26 | return hookResult{ 27 | executable: executable, 28 | stdout: string(stdout), 29 | stderr: stderr.String(), 30 | err: err, 31 | } 32 | } 33 | 34 | type hookResult struct { 35 | executable string 36 | stdout string 37 | stderr string 38 | err error 39 | } 40 | 41 | func (result *hookResult) printStdout() { 42 | prefix := color.RedString("%s", path.Base(result.executable)) 43 | result.printLines(prefix, result.stdout) 44 | } 45 | 46 | func (result *hookResult) printStderr() { 47 | prefix := color.YellowString("%s", path.Base(result.executable)) 48 | result.printLines(prefix, result.stderr) 49 | } 50 | 51 | func (result *hookResult) printLines(prefix, lines string) { 52 | lines = strings.TrimSpace(lines) 53 | if lines == "" { 54 | return 55 | } 56 | for _, line := range strings.Split(lines, "\n") { 57 | if line != "" { 58 | line = " " + line 59 | } 60 | fmt.Printf("%s:%s\n", prefix, line) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /hooks/pre_commit.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | import ( 4 | _ "embed" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "path" 9 | "strings" 10 | 11 | lop "github.com/samber/lo/parallel" 12 | 13 | "github.com/dirk/quickhook/internal" 14 | "github.com/dirk/quickhook/repo" 15 | "github.com/dirk/quickhook/tracing" 16 | ) 17 | 18 | //go:embed pre_commit_git_shim.sh 19 | var PRE_COMMIT_GIT_SHIM string 20 | 21 | const PRE_COMMIT_HOOK = "pre-commit" 22 | const PRE_COMMIT_MUTATING_HOOK = "pre-commit-mutating" 23 | 24 | const FAILED_EXIT_CODE = 65 // EX_DATAERR - hooks didn't pass 25 | const NOTHING_STAGED_EXIT_CODE = 66 // EX_NOINPUT 26 | 27 | type PreCommit struct { 28 | Repo *repo.Repo 29 | } 30 | 31 | // argsFiles can be non-empty with the files passed in by the user when manually running this hook, 32 | // or it can be empty and the list of files will be retrieved from Git. 33 | func (hook *PreCommit) Run(argsFiles []string) error { 34 | // The shimming is really fast, so just do it first with a defer for cleaning up the 35 | // temporary directory. 36 | dirForPath, err := shimGit() 37 | if err != nil { 38 | return err 39 | } 40 | defer os.RemoveAll(dirForPath) 41 | 42 | files, mutatingExecutables, parallelExecutables, err := internal.FanOut3( 43 | func() ([]string, error) { 44 | if len(argsFiles) > 0 { 45 | return argsFiles, nil 46 | } 47 | if files, err := hook.Repo.FilesToBeCommitted(); err != nil { 48 | return nil, err 49 | } else { 50 | return files, nil 51 | } 52 | }, 53 | func() ([]string, error) { 54 | return hook.Repo.FindHookExecutables(PRE_COMMIT_MUTATING_HOOK) 55 | }, 56 | func() ([]string, error) { 57 | return hook.Repo.FindHookExecutables(PRE_COMMIT_HOOK) 58 | }, 59 | ) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | stdin := strings.Join(files, "\n") 65 | 66 | // Run mutating executables sequentially. 67 | for _, executable := range mutatingExecutables { 68 | result := runExecutable(hook.Repo.Root, executable, os.Environ(), stdin) 69 | if hook.checkResult(result) { 70 | os.Exit(FAILED_EXIT_CODE) 71 | } 72 | } 73 | // And the rest in parallel. 74 | results := lop.Map(parallelExecutables, func(executable string, _ int) hookResult { 75 | // Insert the git shim's directory into the PATH to prevent usage of git. 76 | env := append(os.Environ(), fmt.Sprintf("PATH=%s:%s", dirForPath, os.Getenv("PATH"))) 77 | return runExecutable(hook.Repo.Root, executable, env, stdin) 78 | }) 79 | errored := false 80 | for _, result := range results { 81 | errored = hook.checkResult(result) || errored 82 | } 83 | if errored { 84 | os.Exit(FAILED_EXIT_CODE) 85 | } 86 | return nil 87 | } 88 | 89 | // Returns true if the hook errored, false if it did not. 90 | func (hook *PreCommit) checkResult(result hookResult) bool { 91 | if result.err == nil { 92 | // Print any stderr even if the hook executable succeeded. 93 | result.printStderr() 94 | return false 95 | } 96 | // Maybe print a header? 97 | result.printStderr() 98 | result.printStdout() 99 | return true 100 | } 101 | 102 | func shimGit() (string, error) { 103 | actualGit, err := exec.LookPath("git") 104 | if err != nil { 105 | return "", err 106 | } 107 | // Trusting that we didn't get a malicious path back from LookPath(). 108 | templated := strings.Replace(PRE_COMMIT_GIT_SHIM, "ACTUAL_GIT", actualGit, 1) 109 | 110 | span := tracing.NewSpan("shim-git") 111 | defer span.End() 112 | 113 | dir, err := os.MkdirTemp("", "quickhook-git-*") 114 | if err != nil { 115 | return "", err 116 | } 117 | 118 | git := path.Join(dir, "git") 119 | err = os.WriteFile(git, []byte(templated), 0755) 120 | if err != nil { 121 | return "", err 122 | } 123 | return dir, nil 124 | } 125 | -------------------------------------------------------------------------------- /hooks/pre_commit_git_shim.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | COMMAND=$1 4 | shift 5 | if 6 | [ "$COMMAND" = "diff" ] || 7 | [ "$COMMAND" = "grep" ] || 8 | [ "$COMMAND" = "ls-files" ] || 9 | [ "$COMMAND" = "rev-list" ] || 10 | [ "$COMMAND" = "rev-parse" ] || 11 | [ "$COMMAND" = "show" ] || 12 | [ "$COMMAND" = "status" ]; 13 | then 14 | # The Git executable below will be replaced at runtime when shimming. 15 | ACTUAL_GIT "$COMMAND" "$@" 16 | exit $? 17 | fi 18 | COMBINED=$(echo "$COMMAND $*" | xargs) 19 | echo "git is not allowed in parallel hooks (git $COMBINED)" 20 | exit 1 21 | -------------------------------------------------------------------------------- /hooks/pre_commit_test.go: -------------------------------------------------------------------------------- 1 | package hooks 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "sort" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/creack/pty" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | 14 | "github.com/dirk/quickhook/internal/test" 15 | ) 16 | 17 | func initGitForPreCommit(t *testing.T) test.TempDir { 18 | tempDir := test.NewTempDir(t, 1) 19 | tempDir.RequireExec("git", "init", "--initial-branch=main", "--quiet", ".") 20 | tempDir.RequireExec("git", "config", "--local", "user.name", "example") 21 | tempDir.RequireExec("git", "config", "--local", "user.email", "example@example.com") 22 | tempDir.WriteFile([]string{"example.txt"}, "Changed!") 23 | tempDir.RequireExec("git", "add", "example.txt") 24 | return tempDir 25 | } 26 | 27 | func TestFailingHookWithoutPty(t *testing.T) { 28 | tempDir := initGitForPreCommit(t) 29 | tempDir.MkdirAll(".quickhook", "pre-commit") 30 | tempDir.WriteFile( 31 | []string{".quickhook", "pre-commit", "fails"}, 32 | "#!/bin/bash \n printf \"first line\\nsecond line\\n\" \n exit 1") 33 | 34 | output, err := tempDir.ExecQuickhook("hook", "pre-commit") 35 | assert.Error(t, err) 36 | assert.Equal(t, "fails: first line\nfails: second line\n", output) 37 | } 38 | 39 | func TestFailingHookWithPty(t *testing.T) { 40 | ptyTests := []struct { 41 | name string 42 | arg []string 43 | out string 44 | }{ 45 | { 46 | "no args", 47 | []string{}, 48 | "\x1b[31mfails\x1b[0m: first line\r\n\x1b[31mfails\x1b[0m: second line\r\n", 49 | }, 50 | { 51 | "no-color arg", 52 | []string{"--no-color"}, 53 | "fails: first line\r\nfails: second line\r\n", 54 | }, 55 | } 56 | for _, tt := range ptyTests { 57 | t.Run(tt.name, func(t *testing.T) { 58 | tempDir := initGitForPreCommit(t) 59 | tempDir.MkdirAll(".quickhook", "pre-commit") 60 | tempDir.WriteFile( 61 | []string{".quickhook", "pre-commit", "fails"}, 62 | "#!/bin/bash \n printf \"first line\\nsecond line\\n\" \n exit 1", 63 | ) 64 | 65 | cmd := tempDir.NewCommand( 66 | tempDir.Quickhook, 67 | append([]string{"hook", "pre-commit"}, tt.arg...)..., 68 | ) 69 | f, err := pty.Start(cmd) 70 | require.NoError(t, err) 71 | defer func() { _ = f.Close() }() 72 | 73 | var b bytes.Buffer 74 | io.Copy(&b, f) 75 | 76 | assert.Equal(t, tt.out, b.String()) 77 | }) 78 | } 79 | } 80 | 81 | func TestPassesWithNoHooks(t *testing.T) { 82 | tempDir := initGitForPreCommit(t) 83 | tempDir.MkdirAll(".quickhook", "pre-commit") 84 | 85 | output, err := tempDir.ExecQuickhook("hook", "pre-commit") 86 | assert.NoError(t, err) 87 | assert.Equal(t, "", output) 88 | } 89 | 90 | func TestPassesWithPassingHooks(t *testing.T) { 91 | tempDir := initGitForPreCommit(t) 92 | tempDir.MkdirAll(".quickhook", "pre-commit") 93 | tempDir.WriteFile( 94 | []string{".quickhook", "pre-commit", "passes1"}, 95 | "#!/bin/bash \n echo \"passed\" \n exit 0") 96 | tempDir.WriteFile( 97 | []string{".quickhook", "pre-commit", "passes2"}, 98 | "#!/bin/sh \n echo \"passed\"") 99 | 100 | output, err := tempDir.ExecQuickhook("hook", "pre-commit") 101 | assert.NoError(t, err) 102 | assert.Equal(t, "", output) 103 | } 104 | 105 | func TestPassesWithNoFilesToBeCommitted(t *testing.T) { 106 | tempDir := initGitForPreCommit(t) 107 | tempDir.MkdirAll(".quickhook", "pre-commit") 108 | tempDir.WriteFile([]string{".quickhook", "pre-commit", "passes"}, "#!/bin/sh \n echo \"passed\"") 109 | tempDir.RequireExec("git", "commit", "--message", "Commit example.txt", "--quiet", "--no-verify") 110 | 111 | output, err := tempDir.ExecQuickhook("hook", "pre-commit") 112 | assert.NoError(t, err) 113 | assert.Equal(t, "", output) 114 | } 115 | 116 | func TestHandlesDeletedFiles(t *testing.T) { 117 | tempDir := initGitForPreCommit(t) 118 | tempDir.MkdirAll(".quickhook", "pre-commit") 119 | tempDir.WriteFile([]string{".quickhook", "pre-commit", "passes"}, "#!/bin/sh \n echo \"passed\"") 120 | tempDir.RequireExec("git", "commit", "--message", "Commit example.txt", "--quiet", "--no-verify") 121 | tempDir.RequireExec("git", "rm", "example.txt", "--quiet") 122 | tempDir.WriteFile( 123 | []string{"other-example.txt"}, 124 | "Also changed!") 125 | tempDir.RequireExec("git", "add", "other-example.txt") 126 | 127 | output, err := tempDir.ExecQuickhook("hook", "pre-commit") 128 | assert.NoError(t, err) 129 | assert.Equal(t, "", output) 130 | } 131 | 132 | func TestGitShimAllowsReadonlyAccess(t *testing.T) { 133 | tempDir := initGitForPreCommit(t) 134 | tempDir.MkdirAll(".quickhook", "pre-commit") 135 | tempDir.WriteFile([]string{".quickhook", "pre-commit", "accesses-git"}, "#!/bin/sh \n git status") 136 | 137 | output, err := tempDir.ExecQuickhook("hook", "pre-commit") 138 | assert.Nil(t, err) 139 | assert.Empty(t, output) 140 | } 141 | 142 | func TestGitShimDeniesOtherAccess(t *testing.T) { 143 | tempDir := initGitForPreCommit(t) 144 | tempDir.MkdirAll(".quickhook", "pre-commit") 145 | tempDir.WriteFile([]string{".quickhook", "pre-commit", "reset0"}, "#!/bin/sh \n git reset") 146 | tempDir.WriteFile([]string{".quickhook", "pre-commit", "reset1"}, "#!/bin/sh \n git reset --hard") 147 | 148 | output, err := tempDir.ExecQuickhook("hook", "pre-commit") 149 | assert.Error(t, err) 150 | lines := strings.Split(strings.TrimSpace(output), "\n") 151 | sort.Strings(lines) 152 | assert.Equal( 153 | t, 154 | []string{ 155 | "reset0: git is not allowed in parallel hooks (git reset)", 156 | "reset1: git is not allowed in parallel hooks (git reset --hard)", 157 | }, 158 | lines, 159 | ) 160 | } 161 | 162 | func TestMutatingCanAccessGit(t *testing.T) { 163 | ptyTests := []struct { 164 | name string 165 | hook string 166 | out string 167 | }{ 168 | { 169 | "stdout", 170 | "#!/bin/sh \n git status", 171 | "", 172 | }, 173 | { 174 | "stderr", 175 | "#!/bin/sh \n git status 1>&2", 176 | "accesses-git: On branch main", 177 | }, 178 | } 179 | for _, tt := range ptyTests { 180 | t.Run(tt.name, func(t *testing.T) { 181 | tempDir := initGitForPreCommit(t) 182 | tempDir.MkdirAll(".quickhook", "pre-commit-mutating") 183 | tempDir.WriteFile([]string{".quickhook", "pre-commit-mutating", "accesses-git"}, tt.hook) 184 | 185 | output, err := tempDir.ExecQuickhook("hook", "pre-commit") 186 | assert.NoError(t, err) 187 | if tt.out == "" { 188 | assert.Empty(t, output) 189 | } else { 190 | assert.Contains(t, output, tt.out) 191 | } 192 | }) 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /install.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "os" 9 | "path" 10 | "strings" 11 | 12 | "github.com/samber/lo" 13 | 14 | "github.com/dirk/quickhook/repo" 15 | ) 16 | 17 | func install(repo *repo.Repo, quickhook string, prompt bool) error { 18 | hooks, err := listHooks(repo) 19 | if err != nil { 20 | return err 21 | } 22 | 23 | for _, hook := range hooks { 24 | shimPath := path.Join(".git", "hooks", hook) 25 | if prompt { 26 | shouldInstall, err := promptForInstallShim(os.Stdin, repo, shimPath) 27 | if err != nil { 28 | return err 29 | } 30 | 31 | if !shouldInstall { 32 | fmt.Printf("Skipping installing shim %v\n", shimPath) 33 | continue 34 | } 35 | } 36 | 37 | installShim(repo, shimPath, quickhook, hook, prompt) 38 | 39 | fmt.Printf("Installed shim %v\n", shimPath) 40 | } 41 | 42 | return nil 43 | } 44 | 45 | func listHooks(repo *repo.Repo) ([]string, error) { 46 | hooksPath := path.Join(repo.Root, ".quickhook") 47 | 48 | entries, err := ioutil.ReadDir(hooksPath) 49 | if err != nil { 50 | if os.IsNotExist(err) { 51 | fmt.Fprintf(os.Stderr, "Missing hooks directory: %v\n", hooksPath) 52 | os.Exit(66) // EX_NOINPUT 53 | } else { 54 | return nil, err 55 | } 56 | } 57 | 58 | var hooks []string 59 | for _, entry := range entries { 60 | name := entry.Name() 61 | // Rename the mutating hook to the regular pre-commit one. 62 | if name == "pre-commit-mutating" { 63 | name = "pre-commit" 64 | } 65 | isHook := name == "pre-commit" || 66 | name == "commit-msg" 67 | if entry.IsDir() && isHook { 68 | hooks = append(hooks, name) 69 | } 70 | } 71 | return lo.Uniq(hooks), nil 72 | } 73 | 74 | func promptForInstallShim(stdin io.Reader, repo *repo.Repo, shimPath string) (bool, error) { 75 | _, err := os.Stat(path.Join(repo.Root, shimPath)) 76 | exists := true 77 | if os.IsNotExist(err) { 78 | exists = false 79 | } else if err != nil { 80 | return false, err 81 | } 82 | 83 | var message string 84 | if exists { 85 | message = fmt.Sprintf("Overwrite existing file %v?", shimPath) 86 | } else { 87 | message = fmt.Sprintf("Create file %v?", shimPath) 88 | } 89 | 90 | scanner := bufio.NewScanner(stdin) 91 | for { 92 | fmt.Printf("%v [yn] ", message) 93 | 94 | if !scanner.Scan() { 95 | return false, scanner.Err() 96 | } 97 | reply := strings.ToLower(scanner.Text()) 98 | if len(reply) == 0 { 99 | continue 100 | } 101 | 102 | switch reply[0] { 103 | case 'y': 104 | return true, nil 105 | case 'n': 106 | return false, nil 107 | default: 108 | continue 109 | } 110 | } 111 | } 112 | 113 | func installShim(repo *repo.Repo, shimPath, quickhook, hook string, prompt bool) error { 114 | command, err := shimCommandForHook(quickhook, hook) 115 | if err != nil { 116 | return err 117 | } 118 | 119 | lines := []string{ 120 | "#!/bin/sh", 121 | command, 122 | "", // So we get a trailing newline when we join 123 | } 124 | 125 | file, err := os.Create(shimPath) 126 | if err != nil { 127 | return err 128 | } 129 | defer file.Close() 130 | 131 | err = os.Chmod(shimPath, 0755) 132 | if err != nil { 133 | return err 134 | } 135 | 136 | file.WriteString(strings.Join(lines, "\n")) 137 | 138 | return nil 139 | } 140 | 141 | func shimCommandForHook(quickhook, hook string) (string, error) { 142 | var args string 143 | 144 | switch hook { 145 | case "pre-commit": 146 | args = "pre-commit" 147 | case "commit-msg": 148 | args = "commit-msg $1" 149 | default: 150 | return "", fmt.Errorf("invalid hook: %v", hook) 151 | } 152 | 153 | return fmt.Sprintf("%s hook %s", quickhook, args), nil 154 | } 155 | -------------------------------------------------------------------------------- /install_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "path" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | 11 | "github.com/dirk/quickhook/internal/test" 12 | "github.com/dirk/quickhook/repo" 13 | ) 14 | 15 | func TestInstallPreCommitYes(t *testing.T) { 16 | tempDir := test.NewTempDir(t, 0) 17 | tempDir.RequireExec("git", "init", "--quiet", ".") 18 | tempDir.MkdirAll(".quickhook", "pre-commit") 19 | 20 | output, err := tempDir.ExecQuickhook("install", "--yes") 21 | assert.NoError(t, err) 22 | shimPath := path.Join(".git", "hooks", "pre-commit") 23 | assert.Equal(t, 24 | fmt.Sprintf("Installed shim %v", shimPath), 25 | strings.TrimSpace(output)) 26 | assert.FileExists(t, 27 | path.Join(tempDir.Root, shimPath)) 28 | } 29 | 30 | func TestInstallPreCommitMutatingYes(t *testing.T) { 31 | tempDir := test.NewTempDir(t, 0) 32 | tempDir.RequireExec("git", "init", "--quiet", ".") 33 | tempDir.MkdirAll(".quickhook", "pre-commit-mutating") 34 | 35 | output, err := tempDir.ExecQuickhook("install", "--yes") 36 | assert.NoError(t, err) 37 | shimPath := path.Join(".git", "hooks", "pre-commit") 38 | assert.Equal(t, 39 | fmt.Sprintf("Installed shim %v", shimPath), 40 | strings.TrimSpace(output)) 41 | assert.FileExists(t, 42 | path.Join(tempDir.Root, shimPath)) 43 | } 44 | 45 | func TestInstallNoQuickhookDirectory(t *testing.T) { 46 | tempDir := test.NewTempDir(t, 0) 47 | tempDir.RequireExec("git", "init", "--quiet", ".") 48 | 49 | output, err := tempDir.ExecQuickhook("install", "--yes") 50 | assert.Error(t, err) 51 | assert.Contains(t, output, "Missing hooks directory") 52 | } 53 | 54 | func TestPromptForInstall(t *testing.T) { 55 | ptyTests := []struct { 56 | name string 57 | stdin string 58 | expected bool 59 | }{ 60 | { 61 | "yes", 62 | "yes\n", 63 | true, 64 | }, 65 | { 66 | "short yes", 67 | "y\n", 68 | true, 69 | }, 70 | { 71 | "no", 72 | "no\n", 73 | false, 74 | }, 75 | { 76 | "short no", 77 | "n\n", 78 | false, 79 | }, 80 | { 81 | "no input", 82 | "", 83 | false, 84 | }, 85 | } 86 | for _, tt := range ptyTests { 87 | t.Run(tt.name, func(t *testing.T) { 88 | tempDir := test.NewTempDir(t, 0) 89 | repo := &repo.Repo{Root: tempDir.Root} 90 | 91 | stdin := strings.NewReader(tt.stdin) 92 | shouldInstall, err := promptForInstallShim(stdin, repo, ".git/hooks/pre-commit") 93 | assert.NoError(t, err) 94 | assert.Equal(t, tt.expected, shouldInstall) 95 | }) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /internal/fanout.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import "github.com/samber/lo" 4 | 5 | // Run three functions which return results or errors in parallel. If any return an error then 6 | // return that error (starting with the first function). If none error then return their results. 7 | func FanOut3[A any, B any, C any]( 8 | a func() (A, error), 9 | b func() (B, error), 10 | c func() (C, error), 11 | ) (A, B, C, error) { 12 | ch1 := lo.Async2(a) 13 | ch2 := lo.Async2(b) 14 | ch3 := lo.Async2(c) 15 | 16 | var zero1 A 17 | var zero2 B 18 | var zero3 C 19 | 20 | r1, err := (<-ch1).Unpack() 21 | if err != nil { 22 | return zero1, zero2, zero3, err 23 | } 24 | r2, err := (<-ch2).Unpack() 25 | if err != nil { 26 | return zero1, zero2, zero3, err 27 | } 28 | r3, err := (<-ch3).Unpack() 29 | if err != nil { 30 | return zero1, zero2, zero3, err 31 | } 32 | 33 | return r1, r2, r3, nil 34 | } 35 | -------------------------------------------------------------------------------- /internal/test/tempdir.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "os" 5 | "os/exec" 6 | "path" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | type TempDir struct { 13 | t *testing.T 14 | Root string 15 | Quickhook string 16 | } 17 | 18 | // Depth should be the depth of the tests from the package root: this is needed 19 | // to correctly find the built quickhook binary for integration testing. 20 | func NewTempDir(t *testing.T, depth int) TempDir { 21 | cwd, err := os.Getwd() 22 | require.NoError(t, err) 23 | 24 | elem := []string{cwd} 25 | for i := 0; i < depth; i++ { 26 | elem = append(elem, "..") 27 | } 28 | elem = append(elem, "quickhook") 29 | 30 | return TempDir{ 31 | t: t, 32 | Root: t.TempDir(), 33 | Quickhook: path.Join(elem...), 34 | } 35 | } 36 | 37 | func (tempDir *TempDir) NewCommand(name string, arg ...string) *exec.Cmd { 38 | cmd := exec.Command(name, arg...) 39 | cmd.Dir = tempDir.Root 40 | return cmd 41 | } 42 | 43 | func (tempDir *TempDir) RequireExec(name string, arg ...string) { 44 | cmd := tempDir.NewCommand(name, arg...) 45 | _, err := cmd.Output() 46 | require.NoError(tempDir.t, err, cmd) 47 | } 48 | 49 | func (tempDir *TempDir) ExecQuickhook(arg ...string) (string, error) { 50 | cmd := tempDir.NewCommand(tempDir.Quickhook, arg...) 51 | output, err := cmd.CombinedOutput() 52 | return string(output), err 53 | } 54 | 55 | func (tempDir *TempDir) WriteFile(relativePath []string, data string) { 56 | fullPath := path.Join(append([]string{tempDir.Root}, relativePath...)...) 57 | err := os.WriteFile(fullPath, []byte(data), 0755) 58 | if err != nil { 59 | tempDir.t.Fatal(err) 60 | } 61 | } 62 | 63 | func (tempDir *TempDir) MkdirAll(relativePath ...string) { 64 | fullPath := path.Join(append([]string{tempDir.Root}, relativePath...)...) 65 | err := os.MkdirAll(fullPath, 0755) 66 | if err != nil { 67 | tempDir.t.Fatal(err) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /quickhook.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/alecthomas/kong" 9 | "github.com/fatih/color" 10 | 11 | "github.com/dirk/quickhook/hooks" 12 | "github.com/dirk/quickhook/repo" 13 | "github.com/dirk/quickhook/tracing" 14 | ) 15 | 16 | const VERSION = "main" 17 | 18 | var cli struct { 19 | Install struct { 20 | Yes bool `short:"y" help:"Assume yes for all prompts"` 21 | Bin string `help:"Path to Quickhook executable to use in the shim (if it's not on $PATH)"` 22 | } `cmd:"" help:"Install Quickhook shims into .git/hooks"` 23 | Hook struct { 24 | PreCommit struct { 25 | Files []string `help:"For testing, supply list of files as changed files"` 26 | } `cmd:"" help:"Run pre-commit hooks"` 27 | CommitMsg struct { 28 | MessageFile string `arg:"" help:"Temp file containing the commit message"` 29 | } `cmd:"" help:"Run commit-msg hooks"` 30 | } `cmd:""` 31 | NoColor bool `env:"NO_COLOR" help:"Don't colorize output"` 32 | Trace bool `env:"QUICKHOOK_TRACE" help:"Enable tracing, writes to trace.out"` 33 | Version kong.VersionFlag `help:"Show version information"` 34 | } 35 | 36 | func main() { 37 | parser, err := kong.New(&cli, 38 | kong.Vars{ 39 | "version": VERSION, 40 | }) 41 | if err != nil { 42 | panic(err) 43 | } 44 | 45 | args := os.Args[1:] 46 | // Print the help if there are no args. 47 | if len(args) == 0 { 48 | parsed := kong.Context{ 49 | Kong: parser, 50 | } 51 | parsed.PrintUsage(false) 52 | parsed.Exit(1) 53 | } 54 | 55 | parsed, err := parser.Parse(args) 56 | parser.FatalIfErrorf(err) 57 | 58 | if cli.Trace { 59 | finish := tracing.Start() 60 | defer finish() 61 | } 62 | 63 | if cli.NoColor { 64 | color.NoColor = true 65 | } 66 | 67 | switch parsed.Command() { 68 | case "install": 69 | repo, err := repo.NewRepo() 70 | if err != nil { 71 | panic(err) 72 | } 73 | 74 | // TODO: Dry run option. 75 | prompt := !cli.Install.Yes 76 | quickhook := strings.TrimSpace(cli.Install.Bin) 77 | if quickhook == "" { 78 | quickhook = "quickhook" 79 | } 80 | err = install(repo, quickhook, prompt) 81 | if err != nil { 82 | panic(err) 83 | } 84 | 85 | case "hook commit-msg ": 86 | repo, err := repo.NewRepo() 87 | if err != nil { 88 | panic(err) 89 | } 90 | 91 | hook := hooks.CommitMsg{ 92 | Repo: repo, 93 | } 94 | err = hook.Run(cli.Hook.CommitMsg.MessageFile) 95 | if err != nil { 96 | panic(err) 97 | } 98 | 99 | case "hook pre-commit": 100 | repo, err := repo.NewRepo() 101 | if err != nil { 102 | panic(err) 103 | } 104 | 105 | hook := hooks.PreCommit{Repo: repo} 106 | err = hook.Run(cli.Hook.PreCommit.Files) 107 | if err != nil { 108 | panic(err) 109 | } 110 | 111 | default: 112 | panic(fmt.Sprintf("Unrecognized command: %v", parsed.Command())) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /repo/git.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import ( 4 | "github.com/samber/lo" 5 | 6 | "github.com/dirk/quickhook/tracing" 7 | ) 8 | 9 | func (repo *Repo) FilesToBeCommitted() ([]string, error) { 10 | span := tracing.NewSpan("git diff") 11 | defer span.End() 12 | lines, err := repo.ExecCommandLines("git", "diff", "--name-only", "--cached") 13 | if err != nil { 14 | return nil, err 15 | } 16 | return lo.Filter(lines, func(line string, index int) bool { 17 | isFile, _ := repo.isFile(line) 18 | return isFile 19 | }), err 20 | } 21 | -------------------------------------------------------------------------------- /repo/repo.go: -------------------------------------------------------------------------------- 1 | package repo 2 | 3 | import ( 4 | "fmt" 5 | "io/fs" 6 | "os" 7 | "os/exec" 8 | "path" 9 | "strings" 10 | 11 | "github.com/dirk/quickhook/tracing" 12 | ) 13 | 14 | type Repo struct { 15 | // Root directory of the repository. 16 | Root string 17 | } 18 | 19 | func NewRepo() (*Repo, error) { 20 | cmd := exec.Command("git", "rev-parse", "--show-toplevel") 21 | output, err := cmd.CombinedOutput() 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | return &Repo{ 27 | Root: strings.TrimSpace(string(output)), 28 | }, nil 29 | } 30 | 31 | func (repo *Repo) FindHookExecutables(hook string) ([]string, error) { 32 | span := tracing.NewSpan("find " + hook) 33 | defer span.End() 34 | 35 | dir := path.Join(".quickhook", hook) 36 | var infos []fs.FileInfo 37 | { 38 | f, err := os.Open(path.Join(repo.Root, dir)) 39 | if err != nil { 40 | if os.IsNotExist(err) { 41 | return []string{}, nil 42 | } 43 | return nil, err 44 | } 45 | defer f.Close() 46 | 47 | // Using Readdir since it returns FileInfo's that include permissions, whereas ReadDir 48 | // returns returns DirEntry's which does not. 49 | infos, err = f.Readdir(-1) 50 | if err != nil { 51 | return nil, err 52 | } 53 | } 54 | 55 | hooks := []string{} 56 | for _, info := range infos { 57 | if info.IsDir() { 58 | continue 59 | } 60 | name := info.Name() 61 | if (info.Mode() & 0111) != 0 { 62 | hooks = append(hooks, path.Join(dir, name)) 63 | } else { 64 | fmt.Fprintf(os.Stderr, "Warning: Non-executable file found in %v: %v\n", dir, name) 65 | } 66 | } 67 | return hooks, nil 68 | } 69 | 70 | // Runs a command with the repo root as the current working directory. Returns the command's 71 | // standard output with whitespace trimmed. 72 | func (repo *Repo) ExecCommand(name string, arg ...string) (string, error) { 73 | cmd := exec.Command(name, arg...) 74 | cmd.Dir = repo.Root 75 | output, err := cmd.Output() 76 | if err != nil { 77 | return "", err 78 | } 79 | return strings.TrimSpace(string(output)), nil 80 | } 81 | 82 | // Runs ExecCommand and splits its output on newlines. 83 | func (repo *Repo) ExecCommandLines(name string, arg ...string) ([]string, error) { 84 | output, err := repo.ExecCommand(name, arg...) 85 | if err != nil { 86 | return nil, err 87 | } 88 | return strings.Split(output, "\n"), nil 89 | } 90 | 91 | func (repo *Repo) isFile(name string) (bool, error) { 92 | stat, err := os.Stat(path.Join(repo.Root, name)) 93 | if err != nil { 94 | if os.IsNotExist(err) { 95 | return false, nil 96 | } 97 | return false, err 98 | } 99 | return !stat.IsDir(), nil 100 | } 101 | -------------------------------------------------------------------------------- /scripts/coverage.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | # Run this from the repository root, not from scripts! 6 | 7 | COVERAGE=$(pwd)/coverage 8 | INTEGRATION_COVERAGE=$COVERAGE/integration 9 | 10 | echo "Rebuilding with coverage enabled..." 11 | go clean 12 | go build -cover 13 | 14 | rm -rf $COVERAGE 15 | mkdir -p $INTEGRATION_COVERAGE 16 | 17 | echo "Running integration tests with coverage written to $INTEGRATION_COVERAGE..." 18 | GOCOVERDIR=$INTEGRATION_COVERAGE go test ./... -count=1 19 | 20 | echo "Running unit tests with coverage written to $UNIT_COVERAGE..." 21 | go test ./... -count=1 -coverprofile=$COVERAGE/unit-coverage.txt 22 | 23 | echo "Converting integration coverage format..." 24 | go tool covdata textfmt -i=$INTEGRATION_COVERAGE -o $COVERAGE/integration-coverage.txt 25 | 26 | echo "Merging coverage..." 27 | cp $COVERAGE/integration-coverage.txt $COVERAGE/coverage.txt 28 | tail -n +2 $COVERAGE/unit-coverage.txt >> $COVERAGE/coverage.txt 29 | 30 | echo "Opening coverage..." 31 | go tool cover -html=$COVERAGE/coverage.txt 32 | -------------------------------------------------------------------------------- /scripts/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | # Install Quickhook in this repository using the locally-built executable. 6 | go build 7 | QUICKHOOK=$(pwd)/quickhook 8 | $QUICKHOOK install --bin=$QUICKHOOK --yes 9 | -------------------------------------------------------------------------------- /tracing/tracing.go: -------------------------------------------------------------------------------- 1 | package tracing 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | type Span struct { 9 | name string 10 | start time.Time 11 | end time.Time 12 | } 13 | 14 | func NewSpan(name string) *Span { 15 | start := time.Now() 16 | span := &Span{ 17 | name: name, 18 | start: start, 19 | end: start, 20 | } 21 | spans = append(spans, span) 22 | return span 23 | } 24 | 25 | func (span *Span) End() { 26 | span.end = time.Now() 27 | } 28 | 29 | func (span *Span) Elapsed() time.Duration { 30 | return span.end.Sub(span.start) 31 | } 32 | 33 | var spans []*Span 34 | 35 | func Start() func() { 36 | spans = []*Span{} 37 | return func() { 38 | fmt.Printf("Traced %v span(s):\n", len(spans)) 39 | for _, span := range spans { 40 | elapsed := span.Elapsed() 41 | millis := elapsed.Milliseconds() 42 | var human string 43 | if millis <= 2 { 44 | human = fmt.Sprintf("%dus", elapsed.Microseconds()) 45 | } else { 46 | human = fmt.Sprintf("%dms", millis) 47 | } 48 | fmt.Printf("%s %s\n", span.name, human) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tracing/tracing_test.go: -------------------------------------------------------------------------------- 1 | package tracing 2 | 3 | import "time" 4 | 5 | func ExampleStart() { 6 | finish := Start() 7 | span := NewSpan("short") 8 | span.end = span.start.Add(time.Nanosecond * 12_300) 9 | span = NewSpan("long") 10 | span.end = span.start.Add(time.Microsecond * 23_400) 11 | finish() 12 | // Output: 13 | // Traced 2 span(s): 14 | // short 12us 15 | // long 23ms 16 | } 17 | --------------------------------------------------------------------------------