TechEarl

Git Hooks: Native Scripts, Husky, and lint-staged

Write and share Git hooks, handle staged files with lint-staged, and understand native core.hooksPath setup, Husky, bypasses and troubleshooting.

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

A Git hook is a local executable that Git runs at a named event. For a basic repository using the default hooks directory, save this as .git/hooks/pre-commit:

bash
#!/bin/sh
# Check the project before committing.
# Author: Ishan Karunaratne - https://techearl.com/git-hooks-explained
npm run lint

Then enable it:

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

This assumes the project already has a working lint script and installed dependencies. A nonzero result blocks the commit. It checks the working tree according to that script, not an isolated copy of exactly what is staged. For staged-file handling, use the lint-staged example below.

What is a Git hook?

Git recognizes specific filenames such as pre-commit, commit-msg and pre-push. A file named pre-commit.sample is only a sample; remove the suffix and make it executable to activate it. The interpreter comes from the shebang, so shell, Python and Node hooks all need their runtime installed where Git runs.

The default location is $GIT_DIR/hooks. In an ordinary checkout that is .git/hooks, but linked worktrees, bare repositories and core.hooksPath can change the layout. Check a configured override with:

bash
git config --show-origin --get core.hooksPath

No output normally means no override is configured. Do not assume .git is always a directory; worktrees can use a .git file pointing to metadata elsewhere.

Common hooks and when they run

HookWhen it runsTypical use
pre-commitBefore recording the commitFast checks or a linter
prepare-commit-msgBefore the commit-message editorPopulate a ticket or template
commit-msgWith the proposed message fileValidate message format
post-commitAfter the commitLocal notifications
pre-pushBefore transferring the pushTests or an accidental-push check
post-checkoutAfter a checkout or branch switchRefresh local derived files
post-mergeAfter a successful mergeReport dependency changes

Blocking behavior belongs to each hook's documented contract. pre-commit, commit-msg and pre-push can reject their operation with a nonzero exit status. A post-commit notification cannot undo the commit that already happened.

Server-side hooks are a different enforcement point. See pre-receive hook declined for rejected pushes and post-receive deployment hooks for deployment behavior.

A pre-commit hook with useful failure output

A little output makes failures easier to diagnose:

bash
#!/bin/sh
# Run the repository lint script before a commit.
# Author: Ishan Karunaratne - https://techearl.com/git-hooks-explained

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

te_log 'running npm run lint'
if ! npm run lint; then
  te_log 'lint failed; commit aborted'
  exit 1
fi
te_log 'lint passed'

For example, a configured linter could report:

text
[pre-commit] running npm run lint
src/login.js: an unused variable failed the lint check
[pre-commit] lint failed; commit aborted

The exact linter output depends on its configuration. Fix the error, stage the intended fix and retry. Git has not created a commit when pre-commit rejects it. An unstaged change can also cause this full-working-tree check to fail, even when the staged change is valid.

Share native hooks with core.hooksPath

Files inside .git/hooks do not travel with a clone. To review and version the scripts, move them into a tracked directory:

bash
mkdir -p .githooks
mv .git/hooks/pre-commit .githooks/pre-commit
chmod +x .githooks/pre-commit
git config --local core.hooksPath .githooks
git add .githooks/pre-commit

Commit the tracked file through the normal project workflow. Each new clone still needs the local activation step:

bash
git config --local core.hooksPath .githooks

Inspect the scripts before enabling them. A tracked hook executes code from the repository, including future updates to that hook; cloning alone deliberately does not activate arbitrary repository scripts. A setup script can make activation convenient without hiding this trust decision.

Choose one owner for core.hooksPath. A native .githooks setup and Husky should not compete to configure the same clone.

Share hooks with Husky

For an npm-based project, the current Husky setup is:

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

init adds a prepare script and creates .husky/pre-commit. Edit that file to contain the command you want:

bash
npm run lint

Commit the project configuration, lockfile and .husky/pre-commit. Husky's current generated launchers live in .husky/_, and it points core.hooksPath there; the editable hook remains in .husky/pre-commit. Do not manually point Git at the wrong directory or copy old Husky bootstrap lines into a current installation.

Installation needs its lifecycle script to run with Git available. A clone with scripts disabled, development dependencies omitted or HUSKY=0 is not automatically protected. Treat the local hook as useful feedback and keep required checks in CI.

Lint only the staged changes with lint-staged

A pipeline that extracts filenames from git diff and sends them to xargs is easy to get wrong. It can split paths with spaces, omit renames, or lint unstaged content in a partially staged file. I use lint-staged for that workflow:

bash
npm install --save-dev lint-staged

Merge this key into package.json; the relevant ESLint and Prettier dependencies and configuration must already exist:

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

Change .husky/pre-commit to:

bash
npx lint-staged

By default, lint-staged creates a backup stash and hides unstaged changes in partially staged files while tasks run, then restores them. Successful task changes are included in the commit. Those safeguards are configurable, so do not disable them casually. Test the setup with a filename containing spaces and with a partially staged file; review the resulting diff when a formatter changes code.

Lint-staged runs commands on matching paths. It is not a substitute for a full build or test suite that depends on the whole project.

Bypassing and disabling hooks

bash
git commit --no-verify -m "Describe the change"
git push --no-verify

For git commit, --no-verify skips pre-commit and commit-msg; it does not skip prepare-commit-msg. For a push, it skips pre-push. A user can also change or remove their local hooks, so they are not a security boundary. Required CI checks and protected-branch policy provide enforcement at the shared repository.

To disable a raw hook temporarily:

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

For a custom hooks path, use the actual configured file. To remove a custom configuration after uninstalling the tool that owns it:

bash
git config --local --unset core.hooksPath

That returns this clone to its default path unless a higher-level Git configuration supplies another value.

Why a hook does not run

Check the filename, executable permission, shebang and current core.hooksPath. Also check whether the event really occurred: an editor opening is not evidence of a completed commit, and an interrupted merge is not a successful post-merge event.

A GUI Git client can have a different PATH from your interactive shell, especially when Node comes from a version manager. Run the command under the same environment and check the tool's own troubleshooting instructions. On Windows, preserve LF line endings in shell hooks. Non-executable hooks may produce Git advice rather than a hard error.

FAQ

The default local hooks are not. Track scripts in a separate directory and opt in with core.hooksPath, or use a reviewed package-manager setup such as Husky.

Only if its commands are designed to do that. A plain npm run lint normally checks working-tree files. lint-staged handles selected files and, by default, hides unstaged changes in partially staged files while its tasks run.

Yes. Local hooks provide feedback, while required server or CI checks enforce shared policy. The --no-verify flag skips specific hooks, not every hook event.

See also

Sources

Authoritative references this article was fact-checked against.

TagsGit HooksGitVersion Controlpre-commithuskyDevOps

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Systems and Network Architect · Chief Technology Officer

Systems and network architect and Chief Technology Officer with more than two decades designing, building, and running production software, cloud and network architecture, Linux systems, and the bare metal underneath them, and lately working AI into the stack. A US Army veteran who served in Operation Iraqi Freedom. What I write here is drawn from the full arc of that work, across architecture, engineering, and operations, not any single job.

Keep reading

Related posts

Create, push, list, and delete git tags: annotated vs lightweight tags, pushing tags to a remote, and removing a tag locally and on origin.

Git Tags: Create, Push, List, and Delete

Create, push, list, and delete git tags from the command line. When to use an annotated tag over a lightweight one, why git push leaves your tags behind, and how to delete a tag on the remote.

Crack bcrypt with hashcat -m 3200, understand why it is thousands of times slower than MD5, what the cost factor does to crack time, and the only attack that makes sense.

How to Crack a bcrypt Hash (and Why It's So Slow)

bcrypt is the hash you mostly cannot crack, and that is the point. I cover the hashcat command (-m 3200), why bcrypt is deliberately glacial, how the cost factor multiplies crack time, realistic GPU expectations, and the only attack worth running against it. Tested on hashcat 7.1.2.

List the files changed in git from the command line: working tree, staged, last commit, between two branches, and piping the list to a linter to check only what changed.

How to List the Files Changed in Git

List the files changed in git: working tree, staged, the last commit, between branches, or since N commits. Then pipe the list straight into a linter so you only check what changed.