/ Tags: DEVELOPER TIPS / Categories: TIPS

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, .env files, 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 filegit 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.rb gets 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-verify isn’t about being blocked, it’s about being blocked without being told what to do. Print the fix command, and mention --no-verify explicitly 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.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More