GitBeginner

Git Hooks Explained: Automate Your Workflow

How Git hooks work, the most useful ones to set up, and why teams reach for Husky instead of raw hooks for anything shared across a repo.

DevFieldGuideJuly 2, 2026 (updated July 21, 2026)6 min read
Share:

Git hooks are scripts that run automatically at specific points in Git's workflow — commit, push, merge — and they're one of the most underused tools for catching mistakes before they leave your machine.

Where hooks live

bash
ls .git/hooks/

Every Git repo has a .git/hooks/ directory with sample hooks (.sample files) that aren't active by default. Removing the .sample extension and making the file executable activates it.

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

The most useful hook: pre-commit

Runs before a commit is finalized — the natural place to catch problems before they enter history.

bash
#!/bin/sh
# .git/hooks/pre-commit
npm run lint
if [ $? -ne 0 ]; then
  echo "Lint failed — commit aborted."
  exit 1
fi

Exiting with a non-zero status blocks the commit. This is the mechanism behind "can't commit code that fails lint" workflows.

pre-push — a second checkpoint

bash
#!/bin/sh
# .git/hooks/pre-push
npm test

Runs before git push sends commits to a remote — a reasonable place for a slower check (like a full test suite) that would be annoying to run on every single commit but is worth enforcing before code reaches a shared branch.

commit-msg — enforcing message format

bash
#!/bin/sh
# .git/hooks/commit-msg
if ! grep -qE "^(feat|fix|docs|chore|refactor)(\(.+\))?: .+" "$1"; then
  echo "Commit message must follow Conventional Commits format."
  exit 1
fi

Useful for teams that rely on commit message conventions for automated changelog generation or semantic versioning.

1

pre-commit

Lint/format the staged files — fast, local, before the commit is finalized.

2

commit-msg

Enforce a message format, if your team relies on one.

3

pre-push

Run the fuller (slower) test suite before code reaches a shared branch.

4

CI

The backstop that can't be bypassed with --no-verify.

The problem with raw hooks: they're not committed to the repo

.git/hooks/ lives inside the .git directory, which isn't tracked by Git itself — so a hook you set up locally doesn't automatically apply to anyone else who clones the repo. This is the main reason raw hooks don't scale to a team by themselves.

Husky — the standard fix

bash
npm install -D husky
npx husky init

Husky stores hook scripts inside the repo (typically in a .husky/ directory) and configures Git to use that directory instead of .git/hooks/ — so hooks get versioned, reviewed, and applied automatically for anyone who runs npm install after cloning.

bash
# .husky/pre-commit
npx lint-staged

Pairing Husky with lint-staged is the common combination — it runs lint/format only on the files actually staged for commit, rather than the entire codebase, keeping the pre-commit check fast even in large repos.

Where hooks fit vs. CI

Hooks catch problems early, on the developer's own machine, before code is even pushed — faster feedback than waiting for CI. But hooks can be bypassed (git commit --no-verify) and only run on whoever has them installed. CI is the backstop that can't be skipped. The two aren't redundant — hooks are for fast, optional-but-encouraged local feedback; CI is the actual enforcement.

Server-side hooks: the ones that can't be bypassed locally

Everything above runs on the developer's own machine, which is exactly why it can be skipped with --no-verify. Git also supports server-side hooks (pre-receive, update, post-receive) that run on the remote when a push is received — these genuinely cannot be bypassed by the person pushing, since they execute on infrastructure the pusher doesn't control:

bash
#!/bin/sh
# pre-receive, on the git server
while read oldrev newrev refname; do
  if git log "$oldrev..$newrev" --format=%s | grep -qi "wip"; then
    echo "Rejected: commit messages containing 'wip' aren't allowed on this branch."
    exit 1
  fi
done

Self-hosted Git servers (a bare repo on a company server, GitLab self-managed) can use these directly. Hosted platforms like GitHub don't expose raw server-side hooks to repo owners — the equivalent enforcement there is branch protection rules and required CI status checks, which serve the same "can't be bypassed by the pusher" purpose through a different mechanism.

A minimal, real lint-staged config

Pairing Husky with lint-staged needs a small config specifying which command runs against which staged file types — worth seeing concretely rather than just described:

json
// package.json
{
  "lint-staged": {
    "*.{js,ts,tsx}": ["eslint --fix", "prettier --write"],
    "*.{json,md}": ["prettier --write"]
  }
}

eslint --fix and prettier --write both mutate the staged files in place, then lint-staged re-stages the fixed versions automatically — so a commit that would have failed on a formatting issue instead just gets auto-fixed and committed cleanly, with no back-and-forth needed from the developer for anything auto-fixable.

Skipping a hook deliberately, when it's actually appropriate

--no-verify isn't purely an anti-pattern — there are legitimate cases for it, like committing a work-in-progress snapshot to a personal branch before switching tasks, where the usual lint/test bar isn't the point of that particular commit:

bash
git commit --no-verify -m "wip: partial refactor, will clean up"

The distinction that matters: using it deliberately and occasionally, for commits that are explicitly not meant to meet the normal bar, is fine. Reaching for it habitually because hooks feel slow or annoying defeats their entire purpose — if that's happening regularly, the actual fix is speeding up the hook (see the lint-staged pattern above), not routing around it by default.

Hooks are one layer of a bigger local-workflow picture — clean commit history often comes from combining hooks with disciplined use of git rebase before opening a PR, and the same "fail fast, cheaply" instinct behind pre-commit checks is exactly what a good GitHub Actions pipeline does at the CI stage.

Common mistakes

Common mistakes
  • Putting a slow full test suite in pre-commit instead of pre-push. A commit that takes 30+ seconds to complete trains people to commit less often (in bigger, riskier chunks) or to reach for --no-verify out of habit — reserve the slowest checks for pre-push.
  • Relying on raw .git/hooks/ scripts for anything the whole team needs. They live outside version control by design, so a hook only one person set up locally silently doesn't apply to anyone else's clone.
  • Writing a hook that fails silently instead of with a clear message and non-zero exit code. If a hook doesn't explicitly exit 1 on failure, Git treats it as passing regardless of what actually happened.
  • Forgetting hooks need to be executable. A hook script that's correct but not chmod +x'd simply doesn't run, with no error to indicate why.
Advertisement

Frequently Asked Questions

Advertisement
DevFieldGuide
DevFieldGuide

Editorial Team

Practical tutorials and developer tools, written and maintained by the DevFieldGuide team.

Enjoyed this article?

Get the next one straight to your inbox, along with the best of what we publish each week.

Related Articles

More in Git

View all