TechEarl

Git Hooks Explained (with a pre-commit Example)

What Git hooks are, where they live, and how to write a pre-commit hook that runs your linter before code can be committed. Plus husky and lint-staged for sharing hooks across a team.

Ishan Karunaratne⏱️ 9 min readUpdated
Share thisCopied
How Git hooks work and how to write a pre-commit hook that runs a linter before every commit

Git hooks are scripts Git runs automatically when certain events happen in a repository: right before a commit, after a commit, before a push, and so on. They live in the .git/hooks directory of every repo. To make one run, drop an executable script with the right name (like pre-commit) into that folder. That is the whole mechanism. The most common use is a pre-commit hook that runs a linter or test and blocks the commit if it fails.

If you are brand new to Git, start with Git for Beginners and the staging area walkthrough first. Hooks make a lot more sense once you understand what a commit actually is.

What is a Git hook?

A hook is just a script that Git triggers at a specific point in its workflow. Git defines a fixed set of event names. When that event fires, Git looks for a file with the matching name in .git/hooks, and if it finds one that is executable, it runs it. If the file is not there, or not executable, Git carries on as if nothing happened.

The script can be written in any language the system can execute: shell, Python, Node, Ruby. Git does not care about the language; it only cares that the file is named correctly and has the executable bit set. The hook's exit code is what matters. For the hooks that run before an action (the "pre" hooks), a non-zero exit code aborts the action. That is how a pre-commit hook can stop a bad commit from happening.

Where do Git hooks live?

Every repository has a .git/hooks directory. When you run git init or git clone (the same step you take when installing Git in an existing repo), Git populates it with a set of sample hooks:

bash
ls .git/hooks
text
applypatch-msg.sample      pre-merge-commit.sample
commit-msg.sample          pre-push.sample
fsmonitor-watchman.sample  pre-rebase.sample
post-update.sample         pre-receive.sample
pre-applypatch.sample      prepare-commit-msg.sample
pre-commit.sample          update.sample

Notice the .sample extension. Git ships these as examples, and the extension is exactly why they do not run: Git only looks for files named pre-commit, not pre-commit.sample. To activate one, copy or rename it to drop the .sample suffix, then make it executable.

The critical thing to understand: .git/hooks is not version-controlled. The entire .git directory is your local repository metadata, and it is never committed or pushed. So a hook you write by hand in .git/hooks lives only on your machine. Your teammates will not get it when they clone. That limitation is the whole reason tools like husky exist, which I cover below.

Client-side vs server-side hooks

Hooks split into two families. This article is about the client-side ones, which run on your own machine during your local workflow. Server-side hooks run on the remote (the repository you push to) and are how a server enforces policy across everyone, which is a separate topic. If you have ever hit a pre-receive hook declined error on push, that was a server-side hook rejecting your push.

Here are the client-side hooks you will reach for most:

HookWhen it runsCommon use
pre-commitBefore the commit is created, after you run git commitLint, run fast tests, block secrets and debug statements
prepare-commit-msgBefore the commit message editor opensPre-fill a message template or ticket number
commit-msgAfter you write the message, before the commit finalizesEnforce a commit message format
post-commitAfter the commit is createdNotifications, logging (cannot block, the commit already happened)
pre-pushBefore refs are pushed to a remoteRun the full test suite, block pushing to a protected branch

The "pre" hooks can abort the action by exiting non-zero. The "post" hooks run after the fact and cannot stop anything; they are for side effects only. For commit message rules specifically, see my notes on commit message conventions, which pair naturally with a commit-msg hook.

A practical pre-commit hook that runs a linter

Here is a pre-commit hook that runs ESLint before allowing a commit, and refuses the commit if linting fails. The same shape works for a pre-commit hook to block committed secrets, which is far cheaper than scrubbing a key out of history after the fact. Save this as .git/hooks/pre-commit:

bash
#!/bin/sh
#
# pre-commit hook: lint staged files before allowing a commit.
# Author: Ishan Karunaratne - https://techearl.com/git-hooks-explained

te_log() {
  printf '[pre-commit] %s\n' "$1"
}

te_run_lint() {
  te_log "running eslint..."
  npx eslint . --quiet
}

if ! te_run_lint; then
  te_log "eslint failed. commit aborted. fix the errors above and try again."
  exit 1
fi

te_log "lint passed."
exit 0

Then make it executable, which is the step everyone forgets:

bash
chmod +x .git/hooks/pre-commit

Now run a commit. If ESLint finds an error, the hook exits with 1, Git aborts, and nothing gets committed:

bash
git add .
git commit -m "add login form"
text
[pre-commit] running eslint...

/src/login.js
  12:7  error  'username' is assigned a value but never used  no-unused-vars

✖ 1 problem (1 error, 0 warnings)

[pre-commit] eslint failed. commit aborted. fix the errors above and try again.

The commit never happened. Fix the lint error, stage the fix, and commit again. This time the hook passes and the commit goes through. (The two helper functions are prefixed te_ purely to keep them out of the way of anything else the shell might have defined; the hook event name itself, pre-commit, is fixed by Git and is never renamed.)

Linting only the staged files

The hook above lints the entire project on every commit, which gets slow. In a real repo you usually want to lint only the files you actually staged. You can list them with git diff:

bash
#!/bin/sh
# Author: Ishan Karunaratne - https://techearl.com/git-hooks-explained

files=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|jsx|ts|tsx)$')

if [ -z "$files" ]; then
  exit 0
fi

echo "$files" | xargs npx eslint --quiet

--cached looks at the staged content (the index), --diff-filter=ACM keeps only added, copied, and modified files (so you do not try to lint a file you just deleted), and grep narrows it to JavaScript and TypeScript files. If nothing relevant is staged, the hook exits early and the commit proceeds.

This is the right idea, but writing the staged-files filtering by hand is fiddly and easy to get wrong. That is exactly the problem lint-staged solves, which leads to the next section.

A common gotcha: hooks can be bypassed

A client-side hook is a convenience, not a security boundary. Anyone can skip it with --no-verify:

bash
git commit -m "wip" --no-verify

That flag tells Git to skip the pre-commit and commit-msg hooks entirely. The same goes for git push --no-verify and pre-push. This is fine, it is sometimes exactly what you want, but it means a client-side hook can never guarantee a rule is followed. For rules you genuinely must enforce, you need a server-side check (a CI pipeline or branch protection on GitHub) in addition to the local hook. A pre-push hook that runs your test suite locally is the fast mirror of that gate; the real enforcement is wiring the same suite into CI with GitHub Actions. Treat the hook as the fast feedback loop, and CI as the enforcement.

Sharing hooks with your team: husky and lint-staged

Because .git/hooks is not committed, a hand-written hook only protects you. The standard fix in the JavaScript world is husky, which stores hook scripts in a tracked directory in your repo and points Git at them, so every teammate gets the same hooks automatically after install.

Install husky and initialize it:

bash
npm install --save-dev husky
npx husky init

husky init creates a .husky/ directory (which you commit) and adds a prepare script to your package.json so husky sets itself up whenever someone runs npm install. Under the hood it points Git's core.hooksPath at .husky/, so Git looks there instead of .git/hooks. The result: your hooks are now in version control and travel with the repo.

husky init also writes a starter .husky/pre-commit. Edit it to run your check:

bash
npm test

That file is just a shell script, the same as a raw hook, except it lives in a tracked folder.

Adding lint-staged

lint-staged is the companion that handles the "only lint the files I staged" problem cleanly, so you do not hand-roll the git diff filtering shown earlier. Install it:

bash
npm install --save-dev lint-staged

Configure which commands run against which staged files in package.json:

json
{
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": "eslint --fix",
    "*.{css,md}": "prettier --write"
  }
}

Then have your husky pre-commit hook call it:

bash
npx lint-staged

Now on every commit, lint-staged figures out exactly which staged files match each glob, runs the matching command against only those files, and (with --fix / --write) restages the auto-fixed result. If anything still fails, it exits non-zero and the commit is blocked, the same as the raw hook earlier but far less brittle.

Raw hooks vs husky + lint-staged

Raw .git/hooks scripthusky + lint-staged
Lives in version controlNo (local only)Yes (.husky/ is committed)
Shared with the teamNo, each person sets it upYes, automatic on npm install
Lints only staged filesYou write the git diff filtering yourselflint-staged handles it
Auto-fixes and restagesManualBuilt in with --fix / --write
Best forLearning the mechanism, solo repos, non-JS projectsAny team JavaScript or TypeScript project
DependenciesNoneTwo dev dependencies

For a solo project or to genuinely understand what a hook is, write a raw pre-commit. For any team JavaScript project, reach for husky and lint-staged: they solve the sharing problem and the staged-files problem at once, and they are the de facto standard.

Disabling or removing a hook

To stop a raw hook from running, either delete the file or remove its executable bit:

bash
chmod -x .git/hooks/pre-commit

To remove a husky hook, delete the corresponding file in .husky/ and commit the change. To uninstall husky entirely, remove the package, delete .husky/, and run git config --unset core.hooksPath so Git goes back to looking in .git/hooks.

Where to go next

Hooks sit on top of the everyday Git workflow, so they are most useful once the basics are second nature. If you are still building those, the Git for Beginners hub ties the whole series together. A pre-commit linter pairs especially well with good commit message habits, a clear team branching workflow, and a settled view on merge vs rebase for keeping history clean. And if you want the server-side counterpart to client hooks, see how to deploy with a post-receive hook and how those same hooks are what produce a pre-receive hook declined error on a locked-down remote.

Sources

Authoritative references this article was fact-checked against.

TagsGit HooksGitVersion Controlpre-commithuskyDevOps

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

How to Use .gitignore (with Examples)

A practical guide to .gitignore: pattern syntax, per-repo vs global ignore, ready-made templates, and the gotcha that trips everyone up - already-tracked files keep showing up.

How to grep and Print a Specific Column (grep + awk)

grep filters lines, awk extracts fields. The classic pipe is grep 'pattern' file | awk '{print $2}'. This covers awk field basics ($1, $NF), custom separators with -F, multi-column output, the cases grep -o and cut cover on their own, and the fact that awk's own pattern match makes the grep half optional.

iftop: See Bandwidth by Connection in Real Time

sudo iftop -i eth0 shows a live, per-connection bandwidth table: which host pairs are moving traffic and at what rate. The interface flag people forget, the -n and -P switches that make the output readable, the 2s/10s/40s columns, the filter syntax, and when nload or iftop is the right tool.