• Skip to main content

JS

Global git hooks

August 11, 2026 by jshort

Every once in a while, you’ll encounter a repository that ships its own hooks and think “I should do that for all my repos too!”, except you never quite get around to it, because there are dozens of repositories in play, some of them are low-traffic while others have esoteric linter configurations.

But what about shared hooks, defined once in your global gitconfig, with a hook runner that’s smart enough to selectively invoke runners based on file types within the delta? That’s a much faster way to cover every repo no matter what.

Enter lefthook. But also pre-commit. And whatever other hook manager you want to wrap.

Lefthook sits in the global gitconfig:

$ git config get --global core.hooksPath
~/dotfiles/git/hooks/globalCode language: Bash (bash)

Lefthook, like most runners, provides opinionated default scripts and assumes it’s being installed in an individual repository. It’s flexible enough to listen when told that its defaults are not required. I use a single multicall script with symlinks for the actions I want to hook – primarily pre-commit and pre-push:

#!/usr/bin/env bash

set -euo pipefail

repo_root=$(git rev-parse --show-toplevel 2>/dev/null || true)
if [[ "${repo_root}" == "${HOME}/upstream"* ]]; then
  echo "skipping hooks in ${repo_root}"
  exit 0
fi

hook=$(basename "$0")
reponame=$(git remote get-url origin 2>/dev/null | gsed 's|.*[/:]||; s|\.git$||' || basename "$(git rev-parse --show-toplevel)")
override="${HOME}/.githooks/repos/${reponame}/lefthook.yml"
global="${HOME}/.githooks/lefthook.yml"

if [[ -f "${override}" ]]; then
  LEFTHOOK_CONFIG="${override}" lefthook run "${hook}" --no-auto-install
elif [[ -f "${global}" ]]; then
  LEFTHOOK_CONFIG="${global}" lefthook run "${hook}" --no-auto-install
fi

for precommit_cfg in .pre-commit-config.yaml .pre-commit-config.yml; do
  if [[ -f "${precommit_cfg}" ]]; then
    echo ""
    echo "running hooks defined in ${precommit_cfg}"
    pre-commit run --hook-stage "${hook}" --config "${precommit_cfg}"
    break
  fi
doneCode language: Bash (bash)

Customize this for your own topology – I use ~/upstream for third party projects I want to be able to read the source of, but not run hooks in. Individual repositories can override the lefthook config file to disable specific linters or add their own. And the team-wide pre-commit config files still get run, even if they are sometimes duplicative.

That global lefthook.yml is straightforward, if somewhat verbose:

---
output:
  - meta
  - success
  - failure
  - skips
  - summary

pre-commit:
  commands:
    protect-main:
      run: |
        branch=$(git branch --show-current)
        case "$branch" in
          main|master|develop)
            echo "Direct commits to '$branch' are not allowed. Create a feature branch first."
            exit 1
            ;;
        esac

    no-cwd-drift:
      skip:
        - run: "! { test -d .claude/hooks || test -f go.work; }"
      run: |
        repo_root=$(git rev-parse --show-toplevel)
        cwd=$(pwd -P)
        if [[ "${cwd}" != "${repo_root}" ]]; then
          echo "error: hooks must run from repo root (${repo_root}), not ${cwd}"
          exit 1
        fi

    whitespace:
      run: git diff-index --check --cached HEAD -- ':!*.patch'

    no-conflict-markers:
      run: |
        files=$(git diff --cached --name-only)
        if [ -n "$files" ]; then
          if echo "$files" | xargs grep -lP '^(<{7}|={7}|>{7})' 2>/dev/null; then
            echo "Conflict markers found in staged files. Resolve before committing."
            exit 1
          fi
        fi

    markdown:
      glob: "*.md"
      exclude:
        - "**/CLAUDE.md"
        - "CLAUDE.md"
        - "**/AGENTS.md"
        - "AGENTS.md"
        - "ai/**"
      run: markdownlint-cli2 --config ~/.markdownlint.yaml {staged_files}

    shellcheck:
      glob: "*.sh"
      run: shellcheck {staged_files}

    python:
      glob: "*.{py,py3}"
      run: ruff check {staged_files}

    javascript:
      glob: "*.{js,jsx,ts,tsx,mjs,cjs}"
      run: eslint {staged_files}

    ruby:
      glob: "*.rb"
      run: rubocop {staged_files}

    golang:
      glob: "{*.go,go.mod,go.sum}"
      run: $HOME/.githooks/linters/golang

    terraform:
      glob: "*.tf"
      run: $HOME/.githooks/linters/terraform

    yaml:
      glob: "*.{yml,yaml}"
      run: $HOME/.githooks/linters/yaml

    php:
      glob: "*.php"
      run: $HOME/.githooks/linters/php

    gitleaks:
      run: $HOME/.githooks/linters/gitleaks

    buf:
      glob: "*.proto"
      run: $HOME/.githooks/linters/buf

    actionlint:
      glob: ".github/workflows/*.{yml,yaml}"
      run: $HOME/.githooks/linters/actionlint

    hadolint:
      glob: "Dockerfile*"
      run: $HOME/.githooks/linters/hadolint

    helm:
      glob: "Chart.yaml"
      run: $HOME/.githooks/linters/helm

    jsonlint:
      glob: "*.json"
      run: $HOME/.githooks/linters/jsonlint

    kubeconform:
      glob: "*.{yml,yaml}"
      run: $HOME/.githooks/linters/kubeconform

    stylelint:
      glob: "*.{css,scss,sass,less}"
      run: $HOME/.githooks/linters/stylelint

    taplo:
      glob: "*.toml"
      run: $HOME/.githooks/linters/taplo

    prettier:
      glob: "*.{md,json,js,jsx,ts,tsx,css,scss,yaml,yml,html}"
      run: $HOME/.githooks/linters/prettier {staged_files}

    prose:
      glob: "*.md"
      skip:
        - run: test ! -f .vale.ini
      run: vale {staged_files}

pre-push:
  commands:
    test:
      # Skip only when the repo has no Go module anywhere (not just at the root).
      skip:
        - run: "! git ls-files '*go.mod' | grep -q ."
      # Run tests per module so repos whose go.mod lives in subdirectories are covered.
      run: |
        rc=0
        for mod in $(git ls-files '*go.mod'); do
          ( cd "$(dirname "$mod")" && go test -race ./... ) || rc=1
        done
        exit $rc

    gocognit:
      skip:
        - run: "! git ls-files '*go.mod' | grep -q ."
      run: $HOME/.githooks/linters/gocognitCode language: YAML (yaml)

That’s the selling point of lefthook – it handles the file name patterns for us, so our hooks can just execute on the file names they’re given.

One trivial caveat: per-repo overrides are only achievable by using the full path to the upstream config file, variable and tilde expansion don’t work here:

$ cat ${HOME}/.githooks/repos/${reponame}/lefthook.yml
extends:
  - /home/<user>/.githooks/lefthook.yml

pre-commit:
  commands:
    prettier:
      skip: trueCode language: YAML (yaml)

That’s it. Global hooks for whatever linting and testing one could want to invoke. No per-repo configuration, no git templates, no pull requests required.

Filed Under: Technical