TechEarl

Git Hooks: Run Scripts on Commit, Push, and Checkout

Git hooks run your scripts automatically on commit, push, and checkout. Where the native hooks live, the common ones, why .git/hooks is not shared, how core.hooksPath fixes that, and the husky v9 setup for JS projects.

Ishan Karunaratne⏱️ 10 min readUpdated
Share thisCopied
Run scripts automatically on commit, push, and checkout with git hooks: the native .git/hooks files, sharing them with core.hooksPath, and the husky v9 setup.

A git hook is a script git runs automatically at a specific point in its workflow: right before a commit is recorded, after you switch branches, before a push hits the remote. You drop an executable script with the right name into a hooks directory and git fires it at the matching moment. The classic use is a pre-commit hook that lints your code and blocks the commit if the lint fails, so broken code never gets recorded in the first place.

Every git repository already has the hooks wired up. Look in .git/hooks/:

bash
ls .git/hooks/

You will see a set of files ending in .sample:

bash
applypatch-msg.sample
commit-msg.sample
post-update.sample
pre-applypatch.sample
pre-commit.sample
pre-push.sample
pre-rebase.sample
prepare-commit-msg.sample
update.sample

Those are templates git ships so you can see the shape of each hook. They do nothing, because the .sample suffix means git ignores them. To activate a hook you create a file with the bare name (no .sample) and make it executable.

Write your first pre-commit hook

A hook is just a script. The first line is a shebang, the rest is whatever you want to run. Here is a pre-commit hook that runs your linter and aborts the commit on a non-zero exit:

bash
#!/bin/sh
npm run lint

Save that as .git/hooks/pre-commit, then make it executable. The executable bit is the part people forget, and a non-executable hook is silently skipped with no error:

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

Now run git commit. If npm run lint exits non-zero, git stops and the commit is never made. A clean lint, exit zero, and the commit goes through. That exit code is the whole contract: a "pre" hook that exits non-zero cancels the operation.

You are not limited to shell. The shebang can point at any interpreter on the box. A Python or Node hook works the same way:

bash
#!/usr/bin/env node

The common hooks and when they fire

There are more than a dozen hooks, but most real work uses a handful. The names tell you the timing: a pre- hook runs before the action and can abort it, a post- hook runs after and is for side effects (it cannot stop anything).

HookFiresTypical use
pre-commitbefore the commit message is even requestedlint, run fast tests, block secrets
commit-msgafter you write the message, before the commit is finalizedenforce a message format (Conventional Commits, a ticket ID)
pre-pushbefore a push transfers objects to the remoterun the full test suite, block a push to main
post-checkoutafter git checkout or git switch changes your working treerebuild a dependency cache, warn on a branch switch
post-mergeafter a merge completes (including a git pull)run npm install when lockfiles changed

pre-commit and pre-push are the two that earn their keep: catch problems locally before they cost a CI run or land in shared history. commit-msg is how teams keep their log machine-readable. The post- hooks are convenience automation, the kind of thing that re-installs dependencies for you when a teammate's merge touched package-lock.json.

The catch: .git/hooks is not shared

Here is the limitation that sends every team to a hook manager. The .git/hooks/ directory lives inside .git/, which is not version-controlled and not part of what gets cloned, pushed, or pulled. Your carefully written pre-commit hook exists only on your machine. A teammate who clones the repo gets none of it, and there is no built-in way to commit a hook so everyone receives it.

That is by design (a hook is arbitrary code that runs on your machine, so git refuses to auto-install one from a clone for security reasons), but it makes "the team should all run this lint check" impossible with native hooks alone.

Share hooks with core.hooksPath

The native fix is core.hooksPath, a config option added in git 2.9 (2016) that points git at a different hooks directory, one you can track in the repo. Create a folder, commit your hooks into it, and tell git to use it:

bash
mkdir .githooks
mv .git/hooks/pre-commit .githooks/pre-commit
git config core.hooksPath .githooks
git add .githooks
git commit -m "Add shared git hooks"

Now the hooks are real files in the repository, reviewed and updated like any other code. The one piece that does not travel automatically is the config line itself: core.hooksPath is local config, so each teammate still has to run git config core.hooksPath .githooks once after cloning. Most teams put that in a setup script, a Makefile target, or a postinstall so it runs on first install. The hooks stay executable in git, so you do not re-chmod after every clone.

This convenience is also the security trade-off, so it is worth being explicit about. The reason git does not auto-install hooks from a clone is that a hook is arbitrary code that runs on your machine. The moment you point core.hooksPath at a tracked directory, you are opting in to running whatever scripts are committed there, by anyone with push access, every time you commit, push, or check out. On an untrusted or unfamiliar repository, read the hooks before you enable them, the same way you would read a build script before running it. Code review on the hooks themselves matters as much as review on the app code.

For JS projects: husky v9

In a Node project the standard tool is husky, which wires up core.hooksPath for you and survives npm install so the whole team is covered automatically. Husky's setup changed completely in v9 (and again from the old v4 era), so any tutorial showing a package.json "husky" block with a "hooks" object, or the even older v0.10 top-level "precommit" script key, is out of date. That entire config-in-package.json approach was removed. Hooks are now plain files under a .husky/ directory.

The current setup is two steps. Install husky as a dev dependency, then initialize it:

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

npx husky init creates the .husky/ directory with a sample pre-commit file, and adds a prepare script to your package.json:

json
{
  "scripts": {
    "prepare": "husky"
  }
}

The prepare script is the magic: npm runs it automatically on every npm install, so when a teammate clones the repo and installs dependencies, husky sets core.hooksPath to .husky/ for them with no extra step. That is the problem core.hooksPath alone leaves you to solve manually, handled.

The flip side is the same trust point as above, and it is easy to miss because it happens silently. Running npm install on a repository activates whatever hooks are committed under .husky/, so on a project you do not control you are running someone else's scripts at install time. That is a known npm supply-chain surface (the prepare and postinstall lifecycle scripts run arbitrary code), so treat committed hooks as code you review, and be wary of enabling them on a repo you do not trust.

To set what a hook does, write the command into the hook file. There is no husky add command and no shebang to manage in v9, just the command itself:

bash
echo "npm test" > .husky/pre-commit

That file is committed, so the hook is shared the moment it lands on the default branch.

Run linters only on staged files with lint-staged

A pre-commit that lints the whole project is slow and noisy: it flags files you never touched. The fix everyone reaches for is lint-staged, which runs your linters against only the files staged for this commit. It pairs with husky directly. Install it, configure which commands run on which file globs, and call it from the hook:

bash
npm install --save-dev lint-staged

A minimal lint-staged config in package.json:

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

Then the pre-commit hook is a single line:

bash
echo "npx lint-staged" > .husky/pre-commit

Now a commit only lints and auto-fixes the files in that commit, which is fast enough that nobody is tempted to skip it.

Bypass a hook when you need to

Hooks block the operation, and sometimes you legitimately need them out of the way: an emergency hotfix, a work-in-progress commit you will squash later, a commit on a machine without the toolchain installed. The --no-verify flag (short form -n) skips the pre-commit and commit-msg hooks:

bash
git commit --no-verify -m "WIP, will fix lint before PR"

git push --no-verify does the same for the pre-push hook. Use it sparingly. The whole point of the hook is to stop bad commits, and a team that bypasses by reflex has no hook at all. The real backstop is CI running the same checks, so a --no-verify locally still gets caught before merge.

FAQ

See also

Sources

Authoritative references this article was fact-checked against.

Tagsgit hookspre-commit hookgithuskylint-stagedcore.hooksPathDevOps

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

Docker Restart Policies and Health Checks

Make containers come back automatically after crashes and reboots, and tell Compose how to wait until a service is actually ready (not just started). Restart policies, HEALTHCHECK, and depends_on: condition: service_healthy.