Skip to content
shellhacks.dev
Git

How to Use Git Hooks pre commit pre push and commit msg

Write Git hooks for pre-commit, commit-msg and pre-push, share them with core.hooksPath, bypass them safely, and know what hooks cannot enforce for a team.

7 min read
Summarize in AI
Illustration for "How to Use Git Hooks pre commit pre push and commit msg": a terminal session, a loop arrow, a git branch graph.

A hook is a script that Git runs at a fixed point in its workflow. They catch mistakes before they leave your machine: a failing linter, a commit message in the wrong format, a push to the wrong branch. They run locally and can be skipped, which shapes how to use them. The examples use Git 2.45 and Bash 5.2; the specific pre-commit use is in the pre-commit hook guide.

A pre-commit hook

Shell
#!/usr/bin/env bash
# .githooks/pre-commit
set -euo pipefail

files=$(git diff --cached --name-only --diff-filter=ACM -z | tr '\0' '\n' | grep -E '\.sh$' || true)
[[ -z $files ]] && exit 0

echo "$files" | xargs shellcheck || {
  echo "pre-commit: shellcheck found problems; fix them or use --no-verify" >&2
  exit 1
}

The hook looks only at staged files, using --cached, so it checks what will be committed and not unrelated changes in the working tree. A non-zero exit cancels the commit. Make the file executable with chmod +x, or Git ignores it silently, a frequent cause of "the hook does not run".

commit-msg and pre-push

Shell
#!/usr/bin/env bash
# .githooks/commit-msg  ($1 is the path to the message file)
if ! grep -qE '^(feat|fix|docs|chore|refactor|test)(\([a-z0-9-]+\))?: .{3,}' "$1"; then
  echo "commit message must look like 'fix(api): handle empty body'" >&2
  exit 1
fi
Shell
#!/usr/bin/env bash
# .githooks/pre-push  (stdin lines: local-ref local-sha remote-ref remote-sha)
while read -r local_ref local_sha remote_ref remote_sha; do
  if [[ $remote_ref == refs/heads/main ]]; then
    echo "pre-push: do not push straight to main" >&2
    exit 1
  fi
done

commit-msg receives the message file as its argument and can reject or edit it. pre-push reads one line per ref from stdin, so it can inspect exactly what is about to be pushed. The format check above follows the convention in conventional commits.

Share hooks with the team

Shell
mkdir .githooks
git config core.hooksPath .githooks
git add .githooks

.git/hooks is not part of the repository, so hooks you place there are never shared. Keeping them in a tracked directory and pointing core.hooksPath at it fixes that, but each clone must run the config command once, so put it in a setup script or make setup target. Frameworks such as the one in the pre-commit framework automate installing and updating hooks.

Which hook fires when

Git ships about two dozen hook points, and most people only ever meet four. The order matters because each one sees different information. pre-commit runs first, before the message editor opens, and has nothing but the index to look at. prepare-commit-msg runs next and can pre-fill the message file, which is how templates and branch-name prefixes are inserted. commit-msg then receives the finished message. post-commit runs last and cannot change the outcome, so it suits notifications and nothing else. On the push side, pre-push runs after the remote has been contacted but before any objects are sent, which is why it receives the remote's current ref values on stdin.

Two hooks sit on the receiving end of a merge or checkout. post-merge and post-checkout fire after the working tree changes, and they are the right place to remind someone that a lockfile moved and dependencies need installing. They cannot veto anything. A rule of thumb that saves confusion: hooks whose names start with pre can stop the operation by exiting non-zero, and hooks that start with post cannot.

The environment a hook runs in

A hook is an ordinary process started by Git, so it inherits Git's environment plus a few variables Git sets itself. The working directory is the top of the working tree for most hooks, but bare repositories and server-side hooks run from the repository directory instead. GIT_DIR and sometimes GIT_INDEX_FILE are set, and that second variable is the source of a classic bug. During git commit -a or git commit path, Git builds a temporary index and points GIT_INDEX_FILE at it. A hook that runs another git command in a different directory inherits that variable and ends up reading the wrong index. If a hook has to change directory and call Git there, unset the variable first.

The PATH is the one your terminal had, which is not true of every Git client. A graphical client launched from a desktop icon often has a shorter PATH than a shell, so a hook that calls node or shellcheck works on the command line and fails in the editor's commit button with "command not found". The practical fix is to call tools through a version manager shim by absolute path, or to have the hook print a clear message when a required tool is missing instead of failing with a bare exit code.

Staged content is not the working tree

The most common correctness bug in a pre-commit hook is checking files on disk when the commit contains something else. A developer stages one hunk with git add -p, leaves a half-finished change in the same file, and commits. A linter that reads the file from disk sees the unfinished edit and may fail, or worse, pass when the staged version is the broken one. To check what will actually be committed, read the blob from the index with git show :path/to/file and pipe it to the tool, or export the whole index into a temporary directory with git checkout-index --prefix=/tmp/check/ -a and run the checks there.

Formatters add a second trap. A hook that rewrites files and then lets the commit proceed has changed the working tree, but the index still holds the unformatted version, so the commit contains the old text. A formatting hook must re-stage the files it touched with git add, and it must not do that to files where the developer staged only part of the change, because re-adding the whole file would sweep in the unstaged hunks. Tools built for this job handle the partial case by stashing the unstaged changes around the run; a hand-written hook usually does not, and failing the commit with a message to format and re-add is the safer behaviour.

Keeping hooks fast and honest

A hook is paid for on every commit, so its cost is multiplied by the number of commits a team makes in a day. Limit it to the changed files, exit early when none match, and do not start a container or a package install. A check that takes half a second is invisible, and one that takes thirty seconds teaches people to type --no-verify by reflex. When a rule matters, run it in both places: a fast version in the hook so the author hears about it while the change is fresh, and the full version in CI where it cannot be skipped.

Output deserves the same care. A hook that fails should say which file, which rule and what to do, in a sentence a tired person can act on. Send those messages to stderr, because Git shows both streams but tools that wrap Git often capture stdout. Exit codes should be plain: zero to allow, one to reject. Unusual codes buy nothing, since Git only checks whether the status is zero.

Finally, make hooks easy to bypass in a documented way for the cases that need it, such as an emergency fix or a merge commit that touches generated files. An environment variable like SKIP_HOOKS=1 read at the top of the script is clearer than teaching everyone --no-verify, and it leaves a trace in shell history that a review can ask about.

Where this does not apply

Hooks are not enforcement: --no-verify skips pre-commit, commit-msg and pre-push, a fresh clone has none installed, and a web editor on the host bypasses them. Anything that must hold goes in CI and in server-side hooks or branch protection, see server-side hooks. Slow hooks, such as running a whole test suite on every commit, get skipped by frustrated developers, so keep them under a few seconds and push the long checks to CI. The hook names and arguments are listed in githooks, and the configuration option in the core.hooksPath documentation.

Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.