· 6 min read

Git hooks for deploy discipline

A three-line pre-push hook stops a specific class of Monday-morning incident. The hook is in the repo. The habit is in the hook.

By Marten

The best git hook I’ve ever written is four lines long. It lives in .git/hooks/pre-push and it refuses to let me push to main if my working tree is dirty.

#!/usr/bin/env bash
remote="$1"
url="$2"

while read local_ref local_sha remote_ref remote_sha; do
  if [[ "$remote_ref" == "refs/heads/main" ]] && ! git diff --quiet; then
    echo "refusing to push to main with a dirty working tree"
    exit 1
  fi
done

It has caught me maybe six times in the last year. Six times I was about to push a commit to main while I had untracked changes on my machine that I thought I had already committed. Each of those times, the push would have shipped code that I had not reviewed in its final state, because the final state would have diverged from what was in the commit.

The hook is not clever. It just refuses the footgun.

The problem it actually solves

The way I ship bugs is rarely by writing them in the code. The way I ship bugs is by thinking I had committed something, not having committed it, pushing what I thought was the final version, and deploying a partial change.

Git, in its default behaviour, will cheerfully let you push HEAD to main while you have unstaged changes that are not in HEAD. It is not a warning, it is not even a hint. The command succeeds. The CI builds. The deploy runs. Production gets a version that is neither what you just wrote nor what you reviewed five minutes ago, but what was committed at some earlier point.

The fix is a pre-push hook that enforces the discipline you meant to have anyway. On pushes to main (or whatever you consider a protected branch), the working tree must be clean.

Where the hook goes, and why that matters

Git hooks live in .git/hooks/. This directory is not tracked by git. It is local to each clone. If you write a hook on your laptop, it only runs on your laptop. Your teammate pushing from their laptop does not get the same protection.

There are three ways to handle this, and I have tried all three.

The first way is to commit the hook into the repo under a tracked directory (say, .githooks/) and tell each developer to set git config core.hooksPath .githooks in their clone. This works, but it relies on everyone remembering to set the config. New clones do not pick up the config automatically.

The second way is to use a bootstrap script that developers run once on first clone, which sets up the hooks path. This is better, but requires the bootstrap step to be documented and remembered. Inevitably it is remembered less over time.

The third way, which I have settled on, is a tool like husky (for Node projects) or pre-commit (for Python) that handles hook installation as part of npm install / pip install. This is the most reliable because it ties the hook installation to a step people do anyway.

For my own projects, I prefer the bootstrap script, because adding a dependency for four lines of shell feels wrong. But if I were running a team of five, I would use the tool.

The hooks that are worth having

Four hooks I end up writing in most projects.

One, pre-push to protected branches refuses dirty working tree. (The example above.)

Two, pre-commit refuses commits with console.log or var_dump or dd() in the diff. This is the developer’s own choice per-language. The hook is a grep and an exit code:

if git diff --cached | grep -E '^\+.*\bvar_dump\s*\('; then
  echo "commit contains var_dump() calls"
  exit 1
fi

Three, commit-msg enforces a regex. I do not use conventional commits, but I do use “ticket ID in the message” on client projects:

msg=$(cat "$1")
if ! echo "$msg" | grep -qE '\[#[0-9]+\]|no-ticket'; then
  echo "commit message needs either [#nnn] or [no-ticket]"
  exit 1
fi

Four, pre-push checks against the branch’s upstream. If git log @{u}.. shows commits I have not pushed, but a teammate has also pushed to the same branch, I want to know before I force-push over their work. This one I leave as a reminder and not a hard block, because force-pushing over feature branches is sometimes legitimate.

The hook that I tried and removed

For a while I had a pre-push hook that ran the test suite. If tests failed, it refused the push. This sounded great.

In practice, it made pushes slow. The test suite took 40 seconds. Every push, even to feature branches, cost 40 seconds I spent watching a terminal. Worse, it encouraged me to --no-verify past the hook when I was pushing a WIP branch, and --no-verify is a habit you do not want to build. Once you are comfortable skipping pre-push, you skip it on the push that mattered too.

I removed the test hook. Tests run in CI. My local workflow now pushes immediately, CI catches failures. The hook is reserved for things that are cheap to check and dangerous to get wrong — dirty working tree, forgotten debug statements, missing ticket references. Nothing that takes more than a second.

The discipline the hook encodes

A hook is not the discipline. The discipline is the habit of cleaning your working tree before you push. The hook is the safety net for when you forget.

The test I apply when deciding whether to add a hook: will this catch a class of mistake I have actually made, or will it feel virtuous and then get bypassed? The first is worth adding. The second is worth skipping.

I have made the “push with dirty working tree” mistake. I have not made the “push with console.log in the code” mistake often enough for a hook to feel necessary on my personal projects. On a team, the calculus is different, because someone else on the team is making the mistake I don’t make, and that’s what the hook is for.

The final shape

The hook lives in the repo. The installation is part of the onboarding script. The hook is strict on things that are cheap to check and cheap to fix, and silent on everything else.

If the hook fails, the message tells you exactly what’s wrong and what to do. Not hook failed, but refusing to push to main with a dirty working tree. The hook’s message is the hook’s user interface, and unhelpful messages are worse than no hook at all, because they create a class of “I don’t know why it’s failing, I’ll just force it” reactions.

A few lines of shell, saved to the right place, running at the right moment. That is most of what makes git hooks worth bothering with.