Pre-commit Hook: Preventing Common Mistakes

A real staged file with a stray TODO. A real .env file. Both genuinely blocked before they land.

A pre-commit hook runs even earlier than commit-msg — before a message is written at all — and has direct access to exactly what is genuinely staged via git diff --cached. This lab writes one real hook covering two real, common mistakes: a staged file still containing a literal "TODO: remove before commit" marker, and a real .env file someone force-added despite it already being gitignored.\n\nBoth get a real, direct rejection — the exact same non-zero-exit mechanism as commit-msg, just checking the real staged tree instead of a message.

A Real Hook, Checking Staged Content

#!/bin/sh
for f in $(git diff --cached --name-only); do
  if [ -f "$f" ] && grep -q "TODO: remove before commit" "$f"; then
    echo "ERROR: $f contains 'TODO: remove before commit'"
    exit 1
  fi
  case "$f" in
    *.env|.env) echo "ERROR: refusing to commit a .env file: $f"; exit 1 ;;
  esac
done
exit 0

git diff --cached --name-only lists exactly the real files about to be committed — the hook inspects each one directly.

Two Real, Separate Rejections

git commit -m "RECIPE-302: Add debug notes"
# ERROR: debug-notes.md contains 'TODO: remove before commit'

git add -f .env
git commit -m "RECIPE-303: Add local env file"
# ERROR: refusing to commit a .env file: .env

Even a real, deliberate -f past .gitignore does not help — the hook checks staged content directly, independent of .gitignore entirely.

pre-commit hook

A real, local script git runs before a commit message is even written, with direct access to the real staged content via git diff --cached. Confirmed directly in this lab: it rejected both a marker string inside a file and a filename pattern, independent of .gitignore.

git diff --cached --name-only

Lists exactly the real files currently staged for the next commit. Confirmed directly in this lab: this is exactly what the hook iterated over to inspect each staged file.

🛑 Write the Real Hook

A real, executable script checking staged content directly.

cd ~/team-recipes && printf '#!/bin/sh\nfor f in $(git diff --cached --name-only); do\n  if [ -f "$f" ] && grep -q "TODO: remove before commit" "$f"; then\n    echo "ERROR: $f contains '"'"'TODO: remove before commit'"'"'"\n    exit 1\n  fi\n  case "$f" in\n    *.env|.env) echo "ERROR: refusing to commit a .env file: $f"; exit 1 ;;\n  esac\ndone\nexit 0\n' > .git/hooks/pre-commit
cd ~/team-recipes && chmod +x .git/hooks/pre-commit
cd ~/team-recipes && cat .git/hooks/pre-commit

student@lab:~$ cd ~/team-recipes && printf '#!/bin/sh\nfor f in $(git diff --cached --name-only); do\n if [ -f "$f" ] && grep -q "TODO: remove before commit" "$f"; then\n echo "ERROR: $f contains '"'"'TODO: remove before commit'"'"'"\n exit 1\n fi\n case "$f" in\n *.env|.env) echo "ERROR: refusing to commit a .env file: $f"; exit 1 ;;\n esac\ndone\nexit 0\n' > .git/hooks/pre-commit student@lab:~$ cd ~/team-recipes && chmod +x .git/hooks/pre-commit student@lab:~$ cd ~/team-recipes && cat .git/hooks/pre-commit #!/bin/sh for f in $(git diff --cached --name-only); do if [ -f "$f" ] && grep -q "TODO: remove before commit" "$f"; then echo "ERROR: $f contains 'TODO: remove before commit'" exit 1 fi case "$f" in *.env|.env) echo "ERROR: refusing to commit a .env file: $f"; exit 1 ;; esac done exit 0

🚫 A Real Stray TODO, Blocked

A staged file with the forbidden marker string.

cd ~/team-recipes && printf 'Debug Notes\n\nTODO: remove before commit - hardcoded test data below.\n' > debug-notes.md
cd ~/team-recipes && git add debug-notes.md
cd ~/team-recipes && git commit -m "RECIPE-302: Add debug notes"

student@lab:~$ cd ~/team-recipes && printf 'Debug Notes\n\nTODO: remove before commit - hardcoded test data below.\n' > debug-notes.md student@lab:~$ cd ~/team-recipes && git add debug-notes.md student@lab:~$ cd ~/team-recipes && git commit -m "RECIPE-302: Add debug notes" ERROR: debug-notes.md contains 'TODO: remove before commit'

🔒 A Real .env File, Blocked Too

Force-added past .gitignore — the hook still catches it.

cd ~/team-recipes && git reset HEAD debug-notes.md && rm debug-notes.md
cd ~/team-recipes && printf 'API_KEY=fake-local-dev-key-not-real\n' > .env
cd ~/team-recipes && git add -f .env
cd ~/team-recipes && git commit -m "RECIPE-303: Add local env file"

student@lab:~$ cd ~/team-recipes && git reset HEAD debug-notes.md && rm debug-notes.md student@lab:~$ cd ~/team-recipes && printf 'API_KEY=fake-local-dev-key-not-real\n' > .env student@lab:~$ cd ~/team-recipes && git add -f .env student@lab:~$ cd ~/team-recipes && git commit -m "RECIPE-303: Add local env file" ERROR: refusing to commit a .env file: .env

✅ A Genuinely Clean Commit

Nothing forbidden this time — the hook lets it through.

cd ~/team-recipes && git reset HEAD .env && rm .env
cd ~/team-recipes && echo 'Freezer Labels v2' > freezer-labels-v2.md
cd ~/team-recipes && git add freezer-labels-v2.md
cd ~/team-recipes && git commit -m "RECIPE-304: Add updated freezer labels guide"

student@lab:~$ cd ~/team-recipes && git reset HEAD .env && rm .env student@lab:~$ cd ~/team-recipes && echo 'Freezer Labels v2' > freezer-labels-v2.md student@lab:~$ cd ~/team-recipes && git add freezer-labels-v2.md student@lab:~$ cd ~/team-recipes && git commit -m "RECIPE-304: Add updated freezer labels guide" [master f098d5b] RECIPE-304: Add updated freezer labels guide 1 file changed, 1 insertion(+) create mode 100644 freezer-labels-v2.md

Lab 3.6.2 complete. A real pre-commit hook, genuinely catching two real mistakes:\n\n\n Stray TODO marker : ✅ genuinely rejected\n Forced .env file : ✅ genuinely rejected\n Clean commit : ✅ genuinely accepted\n

Enable JavaScript to run the live terminal and track your progress.