Git hooks — refuse a bad path before it is trusted

Projects · Guides

Git hooks — refuse a bad path before it is trusted

Done: The sample pre-commit refuses a staged password= line, a clean line commits, and the server hook is still a file you install yourself.

Requirements

Install the public sample hook in a disposable clone and prove it refuses secret-shaped text. This is a lab demo. It is not a CI server.

  • Hardware — any machine with Git
  • OS — Linux, macOS, or Windows with Git 2.x
  • Packages — git
  • Clone — https://www.portfolio.btcbrewery.com/git/git-hooks-examples.git (read-only HTTPS)
  • Identity in the disposable clone — operator@example.lab / Lab operator. Change both. Do not use a production identity.
Hook Where it runs Shipped on the public mirror
hooks/pre-commit client, on git commit yes
pre-receive or update bare repo, before the branch moves no
Flag What it does
--no-verify Skips the client hook. The commit lands unless a server hook refuses it.

Procedure

Step 1 — Clone the sample

A disposable clone keeps this prove off any repo you share. The later commit needs a local author.

  1. Confirm Git 2.x.

    git --version

    Expected: a line that starts git version 2.

  2. Clone the sample into the current directory.

    git clone https://www.portfolio.btcbrewery.com/git/git-hooks-examples.git
    cd git-hooks-examples
  3. Set a local identity on this clone only. Change both values. These git config lines have no --global.

    git config user.email "operator@example.lab"
    git config user.name "Lab operator"
    git config --local --get user.email
    git config --local --get user.name

    Expected:

    operator@example.lab
    Lab operator

A client hook is a courtesy. It runs only on a machine that installed it. The gate that matters sits on the bare repo, before the branch moves. This clone ships the client check. It does not ship pre-receive.

Step 2 — Install the client hook

Git runs .git/hooks/pre-commit only. A file still named pre-commit.sample, or a file without the execute bit, never runs.

  1. Stay in the clone. Copy the published hook into place.

    cp hooks/pre-commit .git/hooks/pre-commit
    chmod +x .git/hooks/pre-commit
    ls -l .git/hooks/pre-commit

    Expected: the name is pre-commit, not pre-commit.sample. The mode has x.

    -rwxr-xr-x 1 <you> <group>  <size>  .git/hooks/pre-commit
  2. Read the hook. It greps the staged diff. It exits 1 on a private-key header, an AWS-shaped access key, or a password= assignment. It does not lint style.

    cat .git/hooks/pre-commit

    Expected:

    #!/bin/sh
    # Demo pre-commit: refuse files that look like secrets. Public sample only.
    if git diff --cached -U0 | grep -Ei 'BEGIN (RSA |OPENSSH )?PRIVATE KEY|AKIA[0-9A-Z]{16}|password\s*[:=]'; then
      echo "pre-commit: secret-shaped string in staged diff" >&2
      exit 1
    fi
    exit 0

Step 3 — Prove the refuse

A gate you never trip is not a gate. Refuse the bad line, then commit a clean line.

  1. Write a one-line file the hook must refuse.

    printf 'password=example\n' > bad.txt
  2. Stage it and commit. The hook must exit non-zero.

    git add bad.txt
    git commit -m "should refuse"

    Expected: the refuse line, and no new commit hash.

    +password=example
    pre-commit: secret-shaped string in staged diff
  3. Confirm the branch did not move. bad.txt stays staged.

    git status

    Expected shape:

    On branch main
    Changes to be committed:
      (use "git restore --staged <file>..." to unstage)
    	new file:   bad.txt
Terminal capture of the public pre-commit sample refusing a staged password assignment in a disposable repository
Step 3 — Hook refuse. Disposable repo on this machine. The public sample hook refuses a staged password= line. No lab remote was contacted.
  1. Unstage and delete the bad file.

    git restore --staged bad.txt
    rm -f bad.txt
  2. Commit a line that does not match. That commit must pass.

    printf 'hello\n' > ok.txt
    git add ok.txt
    git commit -m "clean line"

    Expected shape:

    [main <hash>] clean line
     1 file changed, 1 insertion(+)
     create mode 100644 ok.txt

Step 4 — The server gate

The public mirror is read-only over HTTPS. You cannot push to it. Install the same refuse on a bare repo you own, then push to that.

  1. Create a local bare repo. This is not the public clone.

    mkdir -p "$HOME/lab-git"
    git init --bare "$HOME/lab-git/example.git"

    Expected: Initialized empty Git repository in that path.

  2. Write pre-receive on that bare repo. git diff --cached does not run here. The hook reads the incoming revisions from stdin.

    cat > "$HOME/lab-git/example.git/hooks/pre-receive" <<'EOF'
    #!/bin/sh
    # Demo pre-receive: refuse a push that looks like secrets. Public sample only.
    zero=0000000000000000000000000000000000000000
    while read oldrev newrev refname; do
      [ "$newrev" = "$zero" ] && continue
      if [ "$oldrev" = "$zero" ]; then
        commits=$newrev
      else
        commits=$(git rev-list "$oldrev..$newrev")
      fi
      for c in $commits; do
        if git show --pretty=format: -U0 "$c" | grep -Ei 'BEGIN (RSA |OPENSSH )?PRIVATE KEY|AKIA[0-9A-Z]{16}|password\s*[:=]'; then
          echo "pre-receive: secret-shaped string in push" >&2
          exit 1
        fi
      done
    done
    exit 0
    EOF
    chmod +x "$HOME/lab-git/example.git/hooks/pre-receive"
    ls -l "$HOME/lab-git/example.git/hooks/pre-receive"

    Expected: the file exists and the mode has x.

  3. From the disposable clone, add that bare repo as a remote named local. Push the clean commit.

    git remote add local "$HOME/lab-git/example.git"
    git push local HEAD:main

    Expected: the push succeeds. Shape:

    To /home/<you>/lab-git/example.git
     * [new branch]      HEAD -> main
  4. Create a bad commit that skips the client hook, then push it. The server hook must refuse. Reset the bad commit so it does not stay on your branch.

    printf 'password=example\n' > bad.txt
    git add bad.txt
    git commit --no-verify -m "should refuse on server"
    git push local HEAD:main
    git reset --hard HEAD~1

    Expected from the push: the refuse line, and the branch on local unmoved.

    +password=example
    pre-receive: secret-shaped string in push
    error: failed to push some refs

    Expected from the reset: HEAD is now at <hash> clean line.

This page does not add a push path to the public mirror.

Verification

The bad commit must print the refuse line and leave the branch unmoved:

+password=example
pre-commit: secret-shaped string in staged diff

The clean commit must succeed. Expected shape:

[main <hash>] clean line
 1 file changed, 1 insertion(+)
 create mode 100644 ok.txt

Git does not write an installer log for a hook. The refuse line on stderr is the record.

Next

Install the same refuse as pre-receive on a bare repo you control. Keep the public mirror read-only. Do not push a secret-shaped test to a remote you share.

Lab / demo · git-hooks-examples · Voice: OWNER-VOICE.md · GUIDE-DESIGN-LAW recut 2026-10-04 · figure: disposable local prove