• 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.

Mise-en-place: a better asdf-vm

October 16, 2025 by jshort

In a previous post, I recommended asdf-vm as a general tool manager. Since then, asdf has undergone a rewrite from bash to go, and broken its plugin ecosystem as a consequence.

Enter mise-en-place, a competing tool manager that mostly just works for most tools without plugins, and has gone the extra step of providing binary builds for historical versions of tools like Terraform that were not originally published for alternative architectures like ARM.

Here’s an example direnv integration:

$ echo 'use mise' > .direnv
$ echo 'layout python' >> .direnv
$ mise use python@3.13
$ direnv allowCode language: PHP (php)

Mise’s default config file is mise.toml, but it can read .tool-versions if desired. This can be disabled by setting MISE_OVERRIDE_TOOL_VERSIONS_FILENAMES=none in your environment.

Context-specific K8s cluster and namespace

May 24, 2022 by jshort

In the last post, I shared my context-specific direnv+asdf config for managing interpreters and tools. Since writing that, I attempted to add kubernetes contexts to the mix, and found only dubious advice from other folks advocating wrapper scripts and other oddities.

It’s poorly documented and somewhat counterintuitive, but kubectl supports multiple configuration files in the KUBECONFIG environment variable. Unlike most other unix-like applications however, it treats those configuration files almost exactly backwards – the first definition wins. But it’s enough to get the job done.

First, we create an overlay configuration file, with the minimum amount duplicated from ~/.kube/config.

Here’s the contents of ~/.kube/overlay/project-cluster-namespace. Name and path are completely arbitrary, but I do like to be organized.

contexts:
- context:
    cluster: gke_cluster_name
    namespace: my-application-namespace
    user: gke_authentication_bits
  name: my-favorite-context
current-context: my-favorite-contextCode language: PHP (php)

From there, one need only add a KUBECONFIG environment variable to .envrc as follows:

export KUBECONFIG="${HOME}/.kube/overlay/project-cluster-namespace:${HOME}/.kube/config"Code language: JavaScript (javascript)

Entering the directory with direnv hooks active will yield the following:

direnv: loading ~/projects/my-application
direnv: export +KUBECONFIGCode language: JavaScript (javascript)

The result is a context-specific cluster and namespace without having to deal with clunky kubectl wrapper scripts or extra files in the workspace, and no side effects for other sessions.

Context-specific tool management with direnv

May 4, 2022 by jshort

I’ve tried juggling virtualenvs by hand. Who has the time to manually activate and deactivate when jumping between projects?

I’ve tried language-specific version managers. Sometimes they work. Sometimes you have to wait double-digit seconds for re-shimming.

I’ve tried using tool-specific wrappers like tfenv. The cognitive burden increases exponentially with each one added to the pile.

But none of them solve the entire problem. Maybe this project needs a specific version of Python. Maybe that one is stuck on a weird version of Terraform. Yet another is using a deprecated Helm version, but the package manager only has the latest patch in that series.

Direnv does a lot out of the box, but its strength is configuring projects to use an available version of a tool, it does not provide those versions itself. Some languages have competent version manager tools that are worth using, but operational tools like terraform and kubectl generally lack version managers.

That’s where asdf comes in. Not only is it a joy to type, when combined with direnv it’s actually fast. Putting tool paths into a direnv include means no more waiting for slow re-shimming, running commands manually, or re-hashing your shell cache.

It’s as simple as:

brew install direnv asdf
direnv hook
asdf plugin add direnv
asdf direnv setup --version system

You can then add use asdf to your .envrc file, and the plugin will modify PATH based on .tool-versions, e.g.:

$ echo "use asdf" >> project/.envrc
$ direnv allow project
$ cat << EOF > project/.tool-versions
helm 2.16.3
kubectl 1.20.15
terraform 0.14.11
EOFCode language: PHP (php)

Now, not only is the python virtualenv automatically activated and deactivated, so are those tools:

> $ cd project
direnv: loading ~/project/.envrc
direnv: using asdf
direnv: loading ~/.cache/asdf-direnv/env/2471675625-973451084-2702573808-1004245725
direnv: using asdf terraform 0.14.11
direnv: using asdf kubectl 1.20.15
direnv: using asdf helm 2.16.3
direnv: export ~PATHCode language: JavaScript (javascript)

But what about virtual environments?

asdf is great for managing tool versions, but some tools have baggage. Like python virtual environments. For that, we’ll need something a bit more specialized: pyenv.

$ brew install pyenv
$ echo _PYENV_DIR="$(pyenv prefix)" >> ~/.zshrc
$ source ~/.zshrcCode language: PHP (php)

direnv “layout” mode places files (such as virtualenvs) into the ~/.direnv folder within the project:

$ echo "layout pyenv 3.10.10" >> project/.envrc
$ direnv allow project
$ cd project
direnv: loading ~/project/.envrc
direnv: export +PYENV_VERSION +VIRTUAL_ENV ~PATH

$ which python
/redacted/project/.direnv/python-3.10.10/bin/pythonCode language: JavaScript (javascript)

Python packages can be managed normally from here via pip. Similar plugins exist for node, ruby, etc, and can be found in the direnv stdlib man page.