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.
-
Confirm Git 2.x.
git --versionExpected: a line that starts
git version 2. -
Clone the sample into the current directory.
git clone https://www.portfolio.btcbrewery.com/git/git-hooks-examples.git cd git-hooks-examples -
Set a local identity on this clone only. Change both values. These
git configlines 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.nameExpected:
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.
-
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-commitExpected: the name is
pre-commit, notpre-commit.sample. The mode hasx.-rwxr-xr-x 1 <you> <group> <size> .git/hooks/pre-commit -
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-commitExpected:
#!/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.
-
Write a one-line file the hook must refuse.
printf 'password=example\n' > bad.txt -
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 -
Confirm the branch did not move.
bad.txtstays staged.git statusExpected shape:
On branch main Changes to be committed: (use "git restore --staged <file>..." to unstage) new file: bad.txt

password= line. No lab remote was contacted.-
Unstage and delete the bad file.
git restore --staged bad.txt rm -f bad.txt -
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.
-
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 inthat path. -
Write
pre-receiveon that bare repo.git diff --cacheddoes 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. -
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:mainExpected: the push succeeds. Shape:
To /home/<you>/lab-git/example.git * [new branch] HEAD -> main -
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~1Expected from the push: the refuse line, and the branch on
localunmoved.+password=example pre-receive: secret-shaped string in push error: failed to push some refsExpected 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.