Guide 05 · Harnesses

Git hooks: stop a commit or a push

Three client-side hooks cover most checks before a commit or a push: pre-commit, commit-msg and pre-push.

Part of:
Hooks overview
Related exhibit:
03 · A hook that keeps a log
Checked against the source:
Nov 4, 2026
Tools:
Git 2.52

What it is for

A git hook runs for every commit or push in the repository, whether a person, a script or an agent starts it. When the hook exits with anything other than 0, git stops the commit or the push.

What you need

No other tools.

Steps

  1. Keep the hooks in the repo

    By default git looks for hooks in .git/hooks, and that folder is not committed. We keep a hooks/ folder in the repo instead and point git at it in step 5. A relative path is read from the root of the working tree.

  2. Stop a commit: pre-commit

    Git runs it with no arguments, before it asks for the message. The change to check is the staged one in the index. The hooks folder is left out, otherwise the hook would find its own marker when you commit it.

    illustrative · hooks/pre-commit · written for this guide, run on Nov 4, 2026
    #!/bin/sh
    # pre-commit: stop the commit if an added line carries the marker below.
    # Git runs it with no arguments; the staged change is in the index.
    # The hooks folder is skipped, so this file does not stop its own commit.
    marker='DO NOT COMMIT'
    if git diff --cached --no-color -U0 -- . ':(exclude)hooks' | grep -q "^+.*$marker"; then
      echo "pre-commit: an added line contains '$marker', commit stopped" >&2
      exit 1
    fi
    exit 0
  3. Check the message: commit-msg

    Git passes the path to the file with the proposed message, and the hook may edit that file. git merge runs it too, so messages that git writes itself are let through.

    illustrative · hooks/commit-msg · our message format, written for this guide
    #!/bin/sh
    # commit-msg: the first line must look like "type(ABC-123): description".
    # Git passes one argument: the file with the proposed message.
    first=$(head -n 1 "$1")
    case "$first" in
      "Merge "*|"Revert "*) exit 0 ;;  # messages git writes itself
    esac
    if ! printf '%s\n' "$first" | grep -Eq '^(feat|fix|docs|chore|refactor|test)\([A-Z]+-[0-9]+\): .+'; then
      echo "commit-msg: expected 'type(ABC-123): description', got: $first" >&2
      exit 1
    fi
    exit 0
  4. Stop a push: pre-push

    Git passes the remote name and URL as arguments, and one line per ref to push on stdin. Nothing is sent until the hook exits with 0. Put the reason on stderr.

    illustrative · hooks/pre-push · written for this guide, run on Nov 4, 2026
    #!/bin/sh
    # pre-push: refuse a direct push to main.
    # Arguments: remote name and URL. Stdin: one line per ref to push:
    # <local-ref> <local-object-name> <remote-ref> <remote-object-name>
    while read -r local_ref local_oid remote_ref remote_oid; do
      if [ "$remote_ref" = "refs/heads/main" ]; then
        echo "pre-push: direct push to main refused ($1), use a branch" >&2
        exit 1
      fi
    done
    exit 0
  5. Activate them

    Add the .gitattributes rule before git add, so the hooks are stored with LF line endings and run the same on Linux and macOS. git update-index --chmod=+x stores them in the index as 100755, so a clone gets them executable. Set core.hooksPath in the repo, not globally. The relative path also works in a linked worktree made with git worktree add.

    adapted from · K-B setup
    printf 'hooks/* text eol=lf\n' >> .gitattributes
    chmod +x hooks/pre-commit hooks/commit-msg hooks/pre-push
    git add .gitattributes hooks
    git update-index --chmod=+x hooks/pre-commit hooks/commit-msg hooks/pre-push
    git config core.hooksPath hooks
    git commit -m "chore(ABC-1): add git hooks"
  6. Decide who may skip them

    git commit --no-verify skips pre-commit and commit-msg; git push --no-verify skips pre-push. prepare-commit-msg runs even then. Whether a check may be skipped is the repo owner's decision, not the agent's. For a check that a second model makes, see Commit review by a second model.

How to check it works

  1. git ls-files -s hooks shows three lines that start with 100755.
  2. Stage a line that contains DO NOT COMMIT and commit. Expect pre-commit: an added line contains 'DO NOT COMMIT', commit stopped.
  3. Stage a clean change and run git commit -m "update stuff". Expect commit-msg: expected 'type(ABC-123): description', got: update stuff.
  4. Run git push origin main. Expect pre-push: direct push to main refused (origin), use a branch.

What to watch out for

Sources

← Hooks overview · ← All guides