Pre-commit is awesome

Long story short, pre-commit is great! Although this is a Python oriented blog and pre-commit happens to be written in Python, pre-commit is basically language independent. If you use git as VCS (who doesn't these days?), keep reading.  I'll go though part of its awesomeness here with a Pythonic use case. If you are hacking around some other language, don't worry, I'm sure you'll get the gist and plenty of ideas about how to utilize it in your Java/C#/JS/whatever masterpieces.

Why

  • Best practices gently forced among all team members or contributors
  • No need to fight about formatting, pre-commit can enforce it
  • Better quality when code hits CI, more green pipelines
  • Better quality when it's time to review, human time is expensive

How

The minimal set-up

  1. .pre-commit-config.yaml inside the repo
  2. pip install pre-commit, or equivalent
  3. pre-commit install
  4. 🍻

It might be a good idea to list those steps in the readme of the project and make it clear that everyone should follow them. Once done, the hooks configured in the pre-commit config file will run on each commit and check the files which are in the changeset. If any of the hooks fails during the commit, the commit itself will fail as well.

Might be also worth to run those steps as part of CI pipeline/build as there will be always that one rebel in the team who won't run them locally. On CI, replace pre-commit install by pre-commit run -a. We don't need to install it on CI as it'll be a one-shot operation. run -a will run the hooks over all files (taking into account excludes if configured).

What kind of hooks to configure

Linting, automatic formatting, safety checks, custom scripts, you name it. The specific needs are unique for each project. Perhaps you'll find some inspiration from the official hooks.

It's always a tradeoff between the benefits gained vs time. In general, the more hooks there are, the longer it'll take. However, pre-commit also provides an option to run the hooks during push instead of commit. It's a considerably better option considering the time usage unless you are one of the mad lads who have one-to-one commit-push-ratio. Instead of pre-commit install, type pre-commit install -t pre-push and you are good to go, they'll run only during push.

pre-commit install is actually a shorthand for pre-commit install -t pre-commit. Uninstalling is done by pre-commit uninstall and the same -t flags can be used there.

Tuning

Perhaps you prefer to run some of the hooks during commit and the rest during push, see top-level default_stages and repo specific stages variables. For example, maybe you want to run the whole test suite or some fast part of it (e.g. smoke tests) during push. If you are battling with a large existing Python test suite and use pytest, markers could be a shortcut to building a fast/smoke suite without a need to modify existing directory structure.

Maybe you'd like to have some hooks which should be ran only when explicitly asked to run, e.g. due to their time consuming nature. There's manual stage option for this, which might be handy in run-only-on-CI cases.

Alright, show me some example

.pre-commit-config.yaml

-   repo: https://github.com/pre-commit/pre-commit-hooks
    sha: v1.2.3
    hooks:
    -   id: check-merge-conflict
    -   id: debug-statements
    -   id: flake8
        args: [--max-line-length=100]
-   repo: https://github.com/ambv/black
    rev: stable
    hooks:
    - id: black
      language_version: python3.6
-   repo: https://github.com/Lucas-C/pre-commit-hooks-bandit
    sha: v1.0.3
    hooks:
    -   id: python-bandit-vulnerability-check
        args: [-l, --recursive, -x, tests]
        files: .py$
-   repo: local
    hooks:
    -   id: tests
        name: run tests
        entry: pytest -v
        language: system
        types: [python]
        stages: [push]

Assuming both pre-commit and pre-push are installed (pre-commit install && pre-commit install -t pre-push), it will:

During commit

  • Check if there are merge conflicts
  • Check if there are debug statements, we don't want those in checked in code
  • Lint with flake8
  • black the code, modifies files in-place if the code in the changeset is not already black compliant and fails the hook
  • Run security checks with bandit, except for files in tests dir

During push

  • All the above
  • Runs also pytest with verbose flag

You'd see something like this in the command line while committing/pushing:

$ git push
Check for merge conflicts..........Passed
Debug Statements (Python)..........Passed
Flake8.............................Passed
black..............................Passed
bandit.............................Passed
run tests..........................Passed

Closing remarks

  • All the pre-commit benefits could be of course achieved in other ways as well. However, I argue that pre-commit beats its competitors in developer experience thanks to it seamless way of execution - no need for manual triggers.
  • It's easy to write own custom hooks and let the whole open source community enjoy them.
  • If you practice heavy TDD and prefer committing failing tests on purpose, then having tests part of pre-commit / pre-push is naturally unacceptable. However, a smoke suite or similar could be still ran as part of commit/push.
  • If you need to cheat a bit, there's always --no-verify.
  • As the ancient proverb says, commit often, push at least once a day, and make sure the quality is high when it hits remote.
Jerry Pussinen

Jerry Pussinen