Every new repo I start gets the same six git hooks copied in before the first commit lands
Pre-commit lint and type-check runs catch noisy mistakes before they hit CI, saving 30 to 60 seconds per push
A commit-msg regex enforces conventional commits so my changelog auto-generates without me thinking about it
Post-merge triggers a script that reinstalls dependencies when package.json changes, ending the "why is this broken" Monday ritual
The whole setup lives in a single hooks/ folder that I clone or curl into new projects in under 30 seconds
I have 15 active repos under RAXXO Studios and one more for my day job. Every single one has the same git hooks folder. Not because I am religious about automation, but because I got tired of the same six mistakes repeating across every project: broken lockfiles, malformed commit messages, missing type checks, stale dependencies, accidentally committed env files, and the classic push-to-main-on-Friday reflex.
Git hooks solve all of them for free. They run locally, cost nothing, and the setup takes 30 seconds per new project once you have the folder copied somewhere you can reach.
Here are the six hooks I copy into every new repo, what they actually do, and why I bothered to write each one.
Why Git Hooks Beat CI for These Checks
I run CI on every project. GitHub Actions, Vercel preview deployments, the usual stack. CI is not the right place for the checks I am about to describe.
The reason is feedback speed. CI takes 90 seconds to 3 minutes to report a failure. If I push a commit with a type error, I find out three minutes later, context-switch back, fix it, and push again. That is two 3-minute cycles of attention spent on a mistake I could have caught in 2 seconds locally.
Pre-commit hooks run before the commit exists. You get told about the mistake before it becomes a git object. Pre-push hooks run before the push leaves your machine. The feedback loop is tight enough that you actually fix things instead of ignoring the CI email.
The other reason is trust. Hooks that run on every developer's machine mean no one pushes a commit they have not personally verified. On a solo project that is just you, and you are the only person you need to trust. But solo projects grow into team projects, and the hooks you put in on day one set the culture for the team later.
Hook 1: Pre-Commit Lint and Type Check
This is the most important one. Every commit runs the linter and the type checker on the staged files only.
I use lint-staged for this because it handles the "only check what I staged" part for me. The hook is three lines.
#!/bin/sh
# .git/hooks/pre-commit
npx lint-staged
The lint-staged config lives in package.json.
{
"lint-staged": {
"*.{ts,tsx}": ["eslint --fix", "tsc --noEmit"],
"*.{css,scss}": ["stylelint --fix"],
"*.md": ["prettier --write"]
}
}
The tsc --noEmit run is the part most setups skip. Eslint catches syntax and style issues, but only tsc catches "you passed a string where a number was expected" across three files. Running it on every commit adds 2 to 4 seconds and catches the exact class of bug that takes 15 minutes to debug from a CI log.
One gotcha. tsc --noEmit without project flags runs against the whole project, not just staged files. For small projects that is fine. For anything over 200 files, use tsc --project tsconfig.json --incremental and cache the build output. The incremental flag cuts subsequent runs from 8 seconds to under 1.
Hook 2: Commit-Msg Conventional Commits Regex
Every commit message in my repos follows conventional commits. feat:, fix:, chore:, docs:, refactor:, test:, perf:. Not because a style guide told me to, but because my release script parses commit messages to auto-generate the changelog.
The hook is a regex check.
#!/bin/sh
# .git/hooks/commit-msg
commit_msg=$(cat "$1")
pattern="^(feat|fix|chore|docs|refactor|test|perf|style|ci|build|revert)(\([a-z0-9-]+\))?: .{3,}$"
if ! echo "$commit_msg" | grep -qE "$pattern"; then
echo "Commit message must follow conventional commits format:"
echo " feat: add login button"
echo " fix(auth): handle expired tokens"
echo ""
echo "Your message: $commit_msg"
exit 1
fi
This catches "updated stuff" and "wip" commits before they happen. The downstream benefit is that my release-please or changesets config can read the log and build a proper changelog automatically. I have not manually written a CHANGELOG entry in 18 months.
Yes, you can bypass this with --no-verify. I do it maybe once a month for a genuine emergency commit. The hook does not need to be bulletproof. It needs to be annoying enough that I write better commit messages by default.
Hook 3: Post-Merge Dependency Sync
This is the hook that saves me the most frustration per month. When I pull changes that modify package.json or package-lock.json, the post-merge hook reinstalls dependencies automatically.
#!/bin/sh
# .git/hooks/post-merge
changed_files="$(git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD)"
check_run() {
echo "$changed_files" | grep --quiet "$1" && eval "$2"
}
check_run package.json "npm install"
check_run package-lock.json "npm install"
check_run bun.lockb "bun install"
check_run pnpm-lock.yaml "pnpm install"
The old Monday ritual was: pull latest, run the dev server, watch it crash because someone added a dependency last week. Then run npm install, wait 40 seconds, try again. This hook makes that automatic. The dev server starts clean every time.
It also works for checkout. Add the same logic to post-checkout and branch switches never leave you with the wrong dependency tree.
Hook 4: Pre-Commit Secret Scan
This is the one that has saved me from public embarrassment at least twice. A grep-based scan of staged files for common secret patterns.
#!/bin/sh
# part of .git/hooks/pre-commit
patterns=(
"AKIA[0-9A-Z]{16}"
"sk-[a-zA-Z0-9]{32,}"
"ghp_[a-zA-Z0-9]{36}"
"xox[baprs]-[0-9a-zA-Z-]+"
"-----BEGIN (RSA |DSA |EC |OPENSSH )?PRIVATE KEY"
)
for pattern in "${patterns[@]}"; do
if git diff --cached | grep -qE "$pattern"; then
echo "BLOCKED: Possible secret detected matching pattern: $pattern"
echo "If this is a false positive, use git commit --no-verify"
exit 1
fi
done
The patterns cover AWS access keys, OpenAI keys, GitHub personal access tokens, Slack tokens, and SSH private keys. That is not exhaustive. For stricter scanning, use gitleaks or trufflehog as the pre-commit command. For a solo project where I mostly just need to catch "oh no I pasted my API key into a config file" the grep version is enough.
The reason this matters. Even if you delete the secret in the next commit, the old commit is still in git history. Rotating the key is the only real fix, and rotating an API key at 11pm on a Sunday because a crawler scraped your public repo is a bad time. Better to block the commit.
Hook 5: Pre-Push Branch Protection
This is a simple check that prevents me from pushing directly to main.
#!/bin/sh
# .git/hooks/pre-push
protected_branch="main"
current_branch=$(git symbolic-ref HEAD | sed -e 's,.*/\(.*\),\1,')
if [ "$current_branch" = "$protected_branch" ]; then
echo "Direct push to $protected_branch is blocked."
echo "Create a feature branch and open a PR instead."
echo "Use git push --no-verify to override in emergencies."
exit 1
fi
On solo projects this sounds overkill. It is not. The times I broke production were always direct pushes to main on a Friday evening when I was tired. A one-line speed bump forces me to at least create a branch, which forces me to at least pause and think about whether this needs a PR review from future-me.
Bypass with --no-verify for the genuine emergency fixes. The friction is the point.
Hook 6: Post-Commit CLAUDE.md Reminder
This is the most RAXXO-specific hook, but the pattern generalizes. If a commit touches specific files that come with a "please also update X" obligation, the post-commit hook reminds me.
#!/bin/sh
# .git/hooks/post-commit
last_commit_files=$(git show --pretty="" --name-only HEAD)
if echo "$last_commit_files" | grep -q "package.json"; then
echo ""
echo "REMINDER: package.json changed. Update CLAUDE.md if you added a new dependency you want Claude to know about."
fi
if echo "$last_commit_files" | grep -q "hooks/"; then
echo ""
echo "REMINDER: hooks folder changed. Update the hook count in CLAUDE.md."
fi
if echo "$last_commit_files" | grep -qE "\.env"; then
echo ""
echo "WARNING: You committed a file matching .env pattern. Double-check this was intentional."
fi
In a team setting you would use this for "you changed the schema, update the API docs" or "you added a migration, update the runbook". The point is that the reminder runs in your terminal, right after the commit, when the context is still fresh.
Bottom Line
Six hooks, one folder, copied into every new repo. The total setup is maybe 80 lines of shell, committed to a dotfiles repo that I clone into new projects with one command.
curl -sL https://raw.githubusercontent.com/yourname/dotfiles/main/install-git-hooks.sh | bash
That is it. No framework, no Husky config, no pre-commit.com yaml file. Just shell scripts in a folder that git already knows to run.
The compounding benefit is that every project, from day one, has the same guardrails. The commit messages are clean, the type checks run, the secrets stay out, and dependencies stay in sync. I have not personally remembered to run npm install after a pull in two years. That is time I get back for work that actually matters.
If you already use Husky or lint-staged, you are 80 percent of the way there. Move the last 20 percent into a plain hooks folder and you will stop fighting your tooling.
Top comments (0)