What this usually means
Pre-commit hooks are shell scripts or executables placed in `.git/hooks/` that run before each commit. When they fail, it's usually due to one of three things: the hook script has a bug (wrong shebang, syntax error, missing dependency), the environment is inconsistent (PATH differs from interactive shell, missing tools), or the hook file permissions are incorrect (not executable). Git runs hooks with a minimal environment, so commands that work in your terminal may fail in the hook context. The error message from git is often terse, but you can capture the hook's stderr by running it manually.
The first ten minutes — establish facts before touching code.
- 1Run `ls -la .git/hooks/pre-commit` to check if the hook file exists and has execute permission (should show `-rwxr-xr-x`).
- 2Execute the hook manually: `.git/hooks/pre-commit` (or `bash .git/hooks/pre-commit`) from the repo root to see the exact error.
- 3Check the top of the hook script for shebang: `head -1 .git/hooks/pre-commit`. It should be `#!/bin/sh` or `#!/usr/bin/env bash`.
- 4Inspect hook output by setting `GIT_TRACE=1 git commit -m 'test'` to see detailed execution trace.
- 5Verify that all tools called inside the hook are installed: run `which eslint`, `which python3`, etc. inside a simulated hook environment: `env -i HOME=$HOME PATH=$PATH bash -c 'which your-tool'`.
The specific files, logs, configs, and dashboards that usually own this bug.
- search.git/hooks/pre-commit — the hook script itself, examine its contents and permissions
- search/tmp/git-hook-stderr.log — create custom logging by redirecting stderr in the hook script
- search$HOME/.gitconfig or project .git/config — check for `core.hooksPath` that may redirect hooks directory
- searchShell profile scripts (~/.bashrc, ~/.zshrc) — environment variables that may not be set in non-interactive shell
- searchCI/CD pipeline logs if the hook fails in automation — compare with local environment
- searchSystemd journal or /var/log/syslog if hook calls system-level commands
- searchLinter/formatter config files (.eslintrc, .prettierrc) — often the hook runs these and they may have issues
Practical causes, not theory. These are the things you will actually find.
- warningHook script is not executable: `chmod +x .git/hooks/pre-commit` fixes it
- warningShebang line is incorrect or missing: script runs with default shell, possibly sh vs bash differences
- warningPATH variable is restricted: hook runs with minimal PATH, so tools like `eslint` or `black` are not found unless using absolute paths or proper env setup
- warningMissing dependencies: the hook calls a tool that isn't installed (e.g., `flake8` not installed globally)
- warningHook script uses interactive-only aliases or functions defined in .bashrc that aren't available
- warningFile contains Windows line endings (CRLF): causes shebang to be `#!/bin/sh\r` which fails
- warningGit hooksPath configuration points to a different directory: `git config core.hooksPath` might be set to a non-existent path
Concrete fix directions. Pick the one that matches your root cause.
- buildFix permissions: `chmod +x .git/hooks/pre-commit`
- buildEnsure correct shebang: use `#!/usr/bin/env bash` for portability
- buildSet explicit PATH in hook: `PATH=/usr/local/bin:/usr/bin:/bin:$PATH` at start of script
- buildConvert line endings: `dos2unix .git/hooks/pre-commit` or use `sed -i 's/\r$//' .git/hooks/pre-commit`
- buildUse `git config core.hooksPath` to set a shared hooks directory for team consistency
- buildAdd error handling and logging: redirect stderr to a file and `exit 1` only on actual failure
- buildUse `git hook run` (Git 2.36+) to test hooks without committing
A fix you cannot prove is a guess. Close the loop.
- verifiedRun `.git/hooks/pre-commit` directly and confirm exit code 0
- verifiedCreate a dummy commit: `git commit --allow-empty -m 'test hook'` and see no failure
- verifiedVerify with `git commit --dry-run` (though it doesn't always run hooks) — better to use `git commit -m 'test'` on a branch you can reset
- verifiedCheck `echo $?` after manual hook execution to confirm 0
- verifiedFor team hooks, have another developer pull and run the same test
Things that make this bug worse or harder to find.
- warningBlindly using `--no-verify` to bypass the hook — delays the problem
- warningEditing the hook in a Windows editor that adds CRLF — use `vim` or VS Code with line ending setting
- warningAssuming the hook runs with the same environment as your login shell — always test in a clean env
- warningIgnoring stderr output — always redirect hook stderr to a log file during debugging
- warningDeleting the hook file — instead, comment out the content to temporarily disable
- warningUsing `exit 0` at the end of the hook script unconditionally — defeats the purpose of the hook
Pre-commit hook fails with 'eslint: command not found' on colleague's machine
Timeline
- 09:15Sarah reports 'pre-commit hook failed' when committing to our shared repo
- 09:20I check my own repo and hook works fine; commit succeeds
- 09:25Sarah runs `.git/hooks/pre-commit` manually — sees 'eslint: command not found'
- 09:30I run `which eslint` on my machine — returns `/usr/local/bin/eslint`
- 09:35Sarah runs `which eslint` — returns `/home/sarah/node_modules/.bin/eslint` (installed locally via npm)
- 09:40We check the hook script: shebang is `#!/bin/sh`, PATH is not set explicitly
- 09:45We add `PATH=/usr/local/bin:/usr/bin:/bin:/home/sarah/node_modules/.bin:$PATH` to top of hook
- 09:50Sarah runs hook manually — succeeds with exit 0
- 09:55Sarah commits — hook passes, commit created
I was midway through a feature when Sarah pinged me that her commit was blocked by a pre-commit hook failure. The hook ran ESLint, and the error was just 'pre-commit hook failed' with no details. I quickly reproduced on my machine — commit went through fine. Classic 'works on my machine' scenario.
I had Sarah run the hook manually: `bash .git/hooks/pre-commit`. That gave us the actual error: 'eslint: command not found'. I checked my PATH and had ESLint globally installed via Homebrew. Sarah had it in her local node_modules. The hook script had no PATH setup, so it ran with Git's minimal PATH that didn't include her local npm bin.
We added an explicit PATH line at the top of the hook that included both global and local npm bin directories. Then we made the hook more robust by using `command -v eslint` to check availability. After that, the hook worked for both of us. The lesson: never rely on the user's shell environment; always set PATH explicitly in hooks.
Root cause
Hook script relied on user's default PATH which didn't include local node_modules/.bin, causing ESLint not found on Sarah's machine.
The fix
Prepend `PATH="/usr/local/bin:/usr/bin:/bin:$(npm bin):$PATH"` to the hook script, where `$(npm bin)` resolves to the local node_modules/.bin directory.
The lesson
Pre-commit hooks run with a clean environment; always set PATH explicitly and test on a clean machine before deploying to the team.
When Git runs a hook, it does not source your shell profile files (.bashrc, .zshrc). It uses a minimal environment: HOME, PATH (usually a default like /usr/bin:/bin), and a few Git-related variables. This means any tool or alias you rely on from your interactive shell is absent unless you explicitly set it in the hook script.
To see exactly what environment the hook sees, add `env > /tmp/hook-env.txt` as the first line of your hook, then run a dummy commit. Inspect that file — you'll likely see PATH missing your custom directories. The fix is to source your profile or set PATH explicitly. Sourcing is fragile because profiles may have interactive-only code. The robust approach is to hardcode the necessary PATH entries.
Git provides a trace mechanism: `GIT_TRACE=1 git commit -m 'test'` will output detailed info about hook execution. This shows if the hook is being found, its exit code, and the command line. It can reveal issues like the hook path being overridden by `core.hooksPath`.
Manual execution is the most direct way: run `bash -x .git/hooks/pre-commit` to get a trace of every command executed inside the hook. The `-x` flag prints each line before execution, showing exactly where it fails. Combine with `set -euo pipefail` at the top of the hook to catch errors early.
The shebang line must be the very first line of the file. If there's a byte order mark (BOM) or whitespace before it, the system may default to a shell that fails. Use `file .git/hooks/pre-commit` to check its type — it should return `POSIX shell script, ASCII text`.
Windows line endings (CRLF) cause the shebang to become `#!/bin/sh\r`, which makes the kernel look for a file literally named `/bin/sh\r` — it doesn't exist. Fix with `dos2unix` or `sed -i 's/\r$//' .git/hooks/pre-commit`. Always store hooks with LF line endings, even on Windows.
Git 2.36 introduced `git hook run <hook-name>` which runs the hook as Git would, but without actually committing. This is safer than creating dummy commits. Example: `git hook run pre-commit`. It uses the configured hooksPath and environment, so it's an accurate test.
If your Git version is older, you can simulate by copying the hook to a temporary repo and running a `git commit --allow-empty`. Use `git worktree add` to create a temporary worktree for testing without affecting your main branch.
Instead of each developer manually copying hook scripts, use `git config core.hooksPath` to point to a version-controlled directory (e.g., `.githooks/`). Then all hooks are shared via the repo. Example: `git config core.hooksPath .githooks`. Ensure the directory is committed and permissions are correct.
Be careful: if a developer clones fresh, they need to run `git config core.hooksPath .githooks` or have a setup script. You can include a `post-checkout` hook that sets it automatically, or use a tool like `husky` for npm projects that manages hooks via package.json.
Frequently asked questions
Why does my pre-commit hook work in the terminal but fail during `git commit`?
Git runs hooks with a stripped-down environment — no aliases, no custom PATH, no shell functions. Your terminal has all your dotfiles sourced. To fix, set PATH explicitly in the hook script, or source your profile at the start (but beware of interactive-only code).
How do I see the actual error message from a failed pre-commit hook?
Run the hook manually: `.git/hooks/pre-commit` (or `bash .git/hooks/pre-commit`). That will output stderr to your terminal. You can also add `exec 2>&1` to redirect stderr to stdout. For persistent logs, add `exec 2>/tmp/hook-errors.log` at the top of your hook.
I see 'Permission denied' when running the hook. What's wrong?
The hook file lacks execute permission. Fix with `chmod +x .git/hooks/pre-commit`. If the file is on a filesystem mounted with `noexec`, you need to move the repo to a different location or use a shebang to run via interpreter: add `#!/bin/sh` and run with `sh .git/hooks/pre-commit`.
Can I bypass the hook temporarily without using `--no-verify`?
Yes, you can skip hooks per commit by setting the `GIT_ALLOW_NO_VERIFY` environment variable: `GIT_ALLOW_NO_VERIFY=1 git commit ...`. Or you can rename the hook file: `mv .git/hooks/pre-commit .git/hooks/pre-commit.disabled`. Remember to restore it.
How do I set up a pre-commit hook for my team that works on both macOS and Linux?
Use a portable shebang like `#!/usr/bin/env bash`, set PATH to include common locations (`/usr/local/bin`, `/opt/homebrew/bin` for Apple Silicon), and avoid platform-specific commands. Test on both OSes. Consider using a hook manager like `pre-commit` framework that handles cross-platform issues.