Setting Up Pre-Commit Hooks for Ruby Projects
A pre-commit hook that runs your full test suite gets bypassed with --no-verify within a week, and then it may as well not exist. A hook that catches a committed binding.pry in 200 milliseconds gets kept forever. The difference is entirely about what you put in it: hooks are for the fast checks that catch embarrassing mistakes, and CI is for everything else.
What Belongs in a Hook
The test is speed and certainty. A check earns its place if it’s fast enough that nobody notices and certain enough that it never blocks a legitimate commit.
Yes:
- Debugger statements —
binding.pry,binding.break,debugger,console.log - Secrets — API keys, private keys,
.envfiles,master.key - Merge conflict markers
- Syntax errors (
ruby -c) on changed files - RuboCop on changed files only
- Trailing whitespace, missing final newline
- Large files committed by accident
No:
- The full test suite — too slow, and the fast feedback belongs in your editor
- Full-codebase RuboCop — seconds instead of milliseconds
- Anything network-bound
- Anything that reformats code without telling you
The budget is about two seconds. Past that, people start reaching for --no-verify, and once that’s a habit the hook protects nothing.
The Hand-Rolled Version
For a small project, one script beats a dependency.
Example:
#!/usr/bin/env bash
# .githooks/pre-commit
set -uo pipefail
RED=$'\033[0;31m'; YELLOW=$'\033[0;33m'; NC=$'\033[0m'
fail=0
staged_rb=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(rb|rake)$' || true)
staged_all=$(git diff --cached --name-only --diff-filter=ACM || true)
# 1. Debugger statements
if [ -n "$staged_rb" ]; then
if hits=$(git diff --cached -U0 -- $staged_rb | grep -nE '^\+.*\b(binding\.(pry|break|b)|debugger|byebug)\b'); then
echo "${RED}✗ Debugger statement in staged changes:${NC}"
echo "$hits"
fail=1
fi
fi
# 2. Secrets and key files
if echo "$staged_all" | grep -qE '(master\.key|credentials/.*\.key|^\.env$|\.pem$|id_rsa)'; then
echo "${RED}✗ Refusing to commit a credential file.${NC}"
fail=1
fi
# 3. Merge conflict markers
if git diff --cached -U0 | grep -qE '^\+(<<<<<<<|=======|>>>>>>>)'; then
echo "${RED}✗ Merge conflict markers in staged changes.${NC}"
fail=1
fi
# 4. Syntax check
for file in $staged_rb; do
if ! ruby -c "$file" >/dev/null 2>&1; then
echo "${RED}✗ Syntax error in $file${NC}"
ruby -c "$file"
fail=1
fi
done
# 5. RuboCop, changed files only
if [ -n "$staged_rb" ] && command -v bundle >/dev/null; then
if ! bundle exec rubocop --force-exclusion --format quiet $staged_rb; then
echo "${YELLOW}→ Run: bundle exec rubocop -a ${staged_rb//$'\n'/ }${NC}"
fail=1
fi
fi
# 6. Large files
for file in $staged_all; do
[ -f "$file" ] || continue
size=$(wc -c < "$file")
if [ "$size" -gt 1000000 ]; then
echo "${RED}✗ $file is $((size / 1024))KB — use Git LFS or reconsider.${NC}"
fail=1
fi
done
[ $fail -eq 0 ] || echo "\nBypass with --no-verify if you're certain."
exit $fail
Three details that make it correct:
--diff-filter=ACM excludes deleted files. Without it, a deleted .rb file gets passed to ruby -c and the hook fails on a file that doesn’t exist.
Grepping the staged diff, not the file — git diff --cached -U0 | grep '^\+' checks only lines you’re adding. Grepping the whole file blocks commits to any file that already contained a binding.pry on an unrelated line.
--force-exclusion on RuboCop — passing explicit filenames bypasses your Exclude config unless you add this flag, so generated files get linted when they shouldn’t.
Sharing Hooks With the Team
.git/hooks/ isn’t version-controlled, so a hook you write only protects you. core.hooksPath fixes that.
Example:
git config core.hooksPath .githooks
chmod +x .githooks/*
Commit .githooks/, and add the config line to your setup script so new clones get it:
# lib/tasks/dev.rake
namespace :dev do
desc "Set up local development environment"
task :setup do
sh "git config core.hooksPath .githooks"
sh "chmod +x .githooks/pre-commit .githooks/commit-msg"
puts "✓ Git hooks installed"
end
end
# bin/setup
bundle install
bin/rails db:prepare
bin/rails dev:setup
The one gap: a developer who never runs bin/setup has no hooks. That’s why CI must enforce the same rules independently — hooks are a fast local convenience, never the enforcement mechanism.
Using Overcommit Instead
For a larger team, overcommit handles installation, parallelism, and per-hook configuration.
Example:
# Gemfile
group :development do
gem "overcommit", require: false
end
# .overcommit.yml
PreCommit:
ALL:
problem_on_unmodified_line: ignore
RuboCop:
enabled: true
on_warn: fail
command: ["bundle", "exec", "rubocop"]
TrailingWhitespace:
enabled: true
MergeConflicts:
enabled: true
YamlSyntax:
enabled: true
BundleCheck:
enabled: true
ForbiddenBranches:
enabled: true
branch_patterns: ["main", "master", "production"]
LocalPryBinding:
enabled: true
CommitMsg:
HardTabs:
enabled: true
TextWidth:
enabled: true
max_subject_width: 72
bundle exec overcommit --install
bundle exec overcommit --sign
problem_on_unmodified_line: ignore is the setting that makes it usable on a legacy codebase — it only reports issues on lines you actually changed.
ForbiddenBranches is worth enabling on its own. It prevents the commit-directly-to-main accident, which is both common and annoying to undo.
The --sign step is a security feature: overcommit refuses to run hooks whose configuration changed until someone re-signs, so a malicious PR can’t add a hook that runs arbitrary code on your machine. It’s mildly irritating and correct.
Secret Scanning
Grep patterns catch the obvious files. They don’t catch a Stripe key pasted into a service object, which is how leaks actually happen.
Example:
# .githooks/pre-commit — add this
if command -v gitleaks >/dev/null 2>&1; then
if ! gitleaks protect --staged --no-banner --redact; then
echo "${RED}✗ gitleaks found a potential secret.${NC}"
fail=1
fi
fi
gitleaks protect --staged scans only what you’re about to commit and runs in well under a second. It knows the patterns for the major providers, which no hand-written regex will keep up with.
The commit-msg Hook
Example:
#!/usr/bin/env bash
# .githooks/commit-msg
msg_file="$1"
subject=$(head -1 "$msg_file")
# Allow merge and revert commits through unchanged
grep -qE '^(Merge|Revert)' <<< "$subject" && exit 0
if ! grep -qE '^(feat|fix|docs|style|refactor|perf|test|build|ci|chore)(\(.+\))?!?: .{1,72}$' <<< "$subject"; then
cat <<MSG
✗ Commit subject must follow Conventional Commits:
<type>(<optional scope>): <description>
Types: feat fix docs style refactor perf test build ci chore
Max 72 characters.
You wrote:
$subject
MSG
exit 1
fi
Cheap, instant, and it keeps the history parseable for changelog generation.
Pro-Tip: Make every hook failure print the exact command that fixes it. A hook that says “RuboCop failed” makes the developer go find out which files and which flag; a hook that prints
bundle exec rubocop -a app/services/order.rb app/models/user.rbgets copy-pasted and the problem is gone in four seconds. This sounds cosmetic and it’s the difference between a hook people keep and a hook people bypass — the resentment that leads to--no-verifyisn’t about being blocked, it’s about being blocked without being told what to do. Print the fix command, and mention--no-verifyexplicitly in the failure output. Counterintuitively, telling people how to bypass reduces bypassing: it signals the hook is advisory tooling rather than an obstacle, and people who know they can skip it mostly don’t.
Don’t Rely on Them
Hooks are local, optional, and trivially bypassed. Every rule enforced by a hook must also run in CI:
- name: Lint
run: bundle exec rubocop --parallel
- name: Secret scan
run: gitleaks detect --no-banner --redact
- name: Debugger check
run: |
! grep -rnE '\b(binding\.(pry|break)|debugger)\b' app lib --include='*.rb'
The hook gives you the answer in 200ms instead of four minutes. CI is what makes it a rule.
Conclusion
Keep pre-commit hooks under two seconds and scope them to fast, certain checks — debugger statements, secrets, conflict markers, syntax, and RuboCop on changed files only. Grep the staged diff rather than whole files so pre-existing issues don’t block unrelated commits, and pass --force-exclusion so your RuboCop config still applies. Share them via core.hooksPath with an install step in bin/setup, or use overcommit on a larger team. Print the fix command on every failure. And duplicate every rule in CI, because a hook is a convenience and CI is the enforcement.
FAQs
Q1: How do I skip a hook when I genuinely need to?
git commit --no-verify skips pre-commit and commit-msg. Legitimate for a WIP commit on a personal branch; if you’re using it daily, the hook is too slow or too strict.
Q2: Why doesn’t my hook run?
Usually not executable — chmod +x .githooks/pre-commit. Also check git config core.hooksPath is set, and that the file has no extension.
Q3: Should hooks auto-fix and re-stage?
It’s tempting and it surprises people — you commit code you didn’t review. Prefer failing with the fix command. If you do auto-fix, always git add the result or the fix won’t be in the commit.
Q4: Do hooks work with GUI git clients?
Yes, but the environment differs — a GUI app may not have your shell’s PATH, so bundle can be missing. Use absolute paths or guard with command -v.
Q5: Overcommit or a plain shell script?
Script for a solo project or a small team — no dependency, trivial to read. Overcommit once you want parallel hooks, per-hook config, and guaranteed installation across a larger team.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀