deaddabe

Git Pre-Commit Hook for Rust Projects

Having a CI system is very convenient to ensure that your project contains no bugs. But running tests locally before pushing is also a great solution. By running tests before every commit, we ensure that each of them is working independently (as they should).

I have started to play with Github Actions from quite some time now for my Rust projects on Github. I now basically copy/paste the same .github/workflows/ci.yml file in my projects and the CI is usually up-and-running:

name: Build

on:
  push:
    branches:
      - main
  pull_request:

jobs:
  build:

    runs-on: ubuntu-latest
    strategy:
      matrix:
        rust:
          - stable
          - beta
          - nightly

    steps:
    - uses: actions/checkout@v2
    - uses: actions-rs/toolchain@v1
      with:
        toolchain: ${{ matrix.rust }}
        override: true
    - name: Build
      run: cargo build --verbose
    # clippy
    - run: rustup component add clippy
      if: matrix.rust == 'beta' || matrix.rust == 'stable'
    - working-directory: ${{ matrix.conf.name }}
      name: clippy ${{ matrix.conf.name }}
      run: cargo clippy --all-targets -- -D warnings
      if: matrix.rust == 'beta' || matrix.rust == 'stable'
    # tests
    - name: Run tests
      run: cargo test --verbose

However, many times I push commits and get notified later that the build failed. It even happened once after I published a crate and pushed its release commit to Github. This meant that the published crate had warnings in it! Ups.

The correct solution would have been to first push any feature/bug, wait for the CI, and only then release the crate once the CI is green on the main branch.

One thing that we can do to ensure that each and every commit is correct is to run these actions locally before each commit is made. Manually, of course, but also automatically using the power of git hooks.

First, I took this pre-commit-cargo-fmt Gist on Github in order to block commits if I forgot to run cargo fmt before committing:

#!/bin/bash

diff=$(cargo fmt -- --check)
result=$?

if [[ ${result} -ne 0 ]] ; then
    cat <<\EOF
There are some code style issues, run `cargo fmt` first.
EOF
    exit 1
fi

exit 0

This script only checks for formatting, but I usually run other commands on my codebase manually before committing:

  • cargo clippy --all-targets -- -D warnings to find all clippy warnings; and
  • cargo test to run all of the tests.

Here is the current content of my current pre-commit hook script that I now try to install in each of my local Rust project clones:

#!/bin/sh

set -eu

if ! cargo fmt -- --check
then
    echo "There are some code style issues."
    echo "Run cargo fmt first."
    exit 1
fi

if ! cargo clippy --all-targets -- -D warnings
then
    echo "There are some clippy issues."
    exit 1
fi

if ! cargo test
then
    echo "There are some test issues."
    exit 1
fi

exit 0

I first named the file .git/hooks/pre-commit-rust, but Git really wants to have the exact file name to use the hook. So be sure to name the file .git/hooks/pre-commit exactly. And remind to make it executable.

Note that I am using /bin/sh instead of /bin/bash, so that the startup time of the hook is faster. Indeed, on Debian /bin/sh points to dash, which is faster than bash and POSIX-compliant.

The hook execution time is about 5 second:

$ time ./.git/hooks/pre-commit-hook
...
./.git/hooks/pre-commit-hook  3,83s user 0,63s system 94% cpu 4,712 total

This remains acceptable for me, even when rebasing multiple commits.

With that, happy Rust hacking!