Skip to content

Why hk?

Tools like pre-commit, prek, and lefthook simply shell out to run linters. That means they can't safely run linters in parallel—if two linters try to modify the same file at the same time, there will be a race condition.

hk has a lot of tricks up its sleeve so that it can safely do this. hk maintains a read/write lock for every file being linted. If a linter needs to write, it needs an exclusive lock. Of course if every linter needed a write lock then we wouldn't be able to parallelize—but with hk we avoid write locks through several mechanisms depending on the capabilities of the linter.

How hk avoids write locks

A couple of examples:

ruff – has diff output which is perfect for hk. hk runs ruff format --diff which outputs a diff if there are any changes that need to be made. This way hk can simply apply that diff later itself without needing to shell out to ruff again. (However, if there are conflicts because multiple linters edited the same file, it may need to.)

prettier – supports --list-different, which tells hk which files need formatting without modifying anything. hk uses this to run the check with only a read lock on all targeted files, then narrows down the set of files before running prettier --write.

For linters with none of these capabilities (ahem: eslint), it will fall back to fetching a write lock on all of its files. Other linters that don't touch the same files will still run in parallel. Optionally, you can use the check_first feature to run something like eslint on all files and if that fails run eslint --fix on all of those same files again.

For this reason, hk requires more integration work with each linter. However, since it comes with a large corpus of builtins you likely won't need to deal with the nitty-gritty yourself.

Smart stashing

Here's a scenario every developer hits: you've got a file with some changes staged for commit and other changes you're still working on. You run your pre-commit hooks—and suddenly your unstaged work-in-progress gets formatted, linted, and staged along with everything else. Your careful partial commit is ruined.

hk solves this properly. Before running any fixers, hk stashes your unstaged changes so linters only see what you're actually committing. After linters run, hk restores your unstaged work using a three-way merge:

  1. Before hooks run: hk snapshots your working tree and stashes only the unstaged changes (the diff between your worktree and your index). Your working tree now matches your staged content exactly.
  2. Hooks run: linters and fixers see only the staged content. If prettier reformats a file, it reformats the version you're committing—not your work-in-progress.
  3. After hooks run: hk does a three-way merge to combine the fixer's changes with your unstaged work. If the fixer changed line 5 and your unstaged changes are on line 20, both are preserved. If there's a conflict, your unstaged changes take priority—hk never destroys your in-progress work.

This works even with partially staged hunks in a single file. If you git add -p to stage just one function, hk will lint that function, apply fixes to it, and leave your other unstaged changes in the file untouched.

Other tools either don't stash at all (lefthook), or do basic stashing that can lose changes when fixers and unstaged edits touch the same file (pre-commit, prek).

Built-in utilities

hk ships with fast Rust-native utilities for common tasks like trailing whitespace removal, end-of-file fixing, and merge conflict detection. These run as part of hk itself—no extra tools to install. We plan to add more of these over time so common checks are as fast as possible.

Plugin security

pre-commit and prek download hook implementations from external git repositories. Each hook repo contains its own environment setup, dependencies, and entry points—you're running code pulled from third-party repos.

hk builtins are just Pkl config hosted in the hk repository and fetched via the import statement in your hk.pkl. They define how to invoke linters already on your system—they don't download or execute third-party code. You can read exactly what each builtin does in a few lines of Pkl:

pkl
// This is an entire hk builtin. That's it.
prettier = new Config.Step {
  glob = List("**/*.js", "**/*.ts", "**/*.css", "**/*.json", "**/*.md")
  check = "prettier --check {{ files }}"
  check_list_files = "prettier --list-different {{ files }}"
  fix = "prettier --write {{ files }}"
}

Compare that to pre-commit:

yaml
# pre-commit: hooks are downloaded from external git repos
repos:
  - repo: https://github.com/pre-commit/mirrors-prettier
    rev: v3.1.0
    hooks:
      - id: prettier

vs pre-commit

pre-commit is the most popular hook manager. It's written in Python and requires Python as a runtime dependency.

  • Runs hooks sequentially—hook B waits for hook A to finish completely
  • Downloads hook code from external git repos
  • Requires Python on the system
  • Uses YAML for configuration

vs prek

prek is pre-commit reimplemented in Rust. It's faster than pre-commit but fundamentally the same model:

  • Still runs hooks sequentially—same execution model as pre-commit
  • Still downloads hook code from external git repos
  • prek itself doesn't require Python, but Python-based hooks (which are most of the pre-commit ecosystem) still need Python at runtime—prek just manages it automatically via uv
  • prek does have some built-in Rust-native hooks for common checks, similar to hk's hk util commands

hk gets its speed from parallelism, not just language choice. Running 10 linters in parallel is fundamentally faster than running them one at a time, regardless of how fast each individual run is.

vs lefthook

lefthook is a hook manager written in Go. It's the closest to hk in philosophy—it supports parallel execution.

The problem: lefthook has no file-level coordination. If two parallel jobs modify the same file, you get a race condition—the last writer wins and the other's changes are silently lost. This means in practice you either accept the risk or configure your hooks to not overlap, which defeats the purpose.

hk solves this with read/write file locks. Multiple linters can safely run in parallel, even when they touch the same files.

Other differences:

  • lefthook has no builtin linter definitions—you write shell commands directly in YAML
  • lefthook does not stash unstaged changes before running fix hooks, which can cause unstaged changes to be erroneously staged
  • lefthook uses YAML; hk uses Pkl for type-safe configuration

vs husky + lint-staged

husky (~35k stars) and lint-staged (~14k stars) are the most popular combination in the JavaScript ecosystem.

husky is intentionally minimal—it just wires up git hooks. lint-staged handles the actual linting orchestration. lint-staged does run tasks matched to different glob patterns in parallel, but tasks targeting the same glob run sequentially. There's no file-level coordination, so if two tasks end up touching the same file through different globs, you get a race condition.

Both require Node.js. If your project isn't a JS project, you're adding a runtime dependency just for your hook manager.

Feature comparison

Featurehkpre-commitlefthookprek
Parallel executionFile-lockedNoUnsafeNo
LanguageRustPythonGoRust
Requires PythonNoYesNoOften*
Config formatPklYAMLYAMLYAML
Built-in linter configs120+
Plugin modelPkl config (local)Git repos (remote)Shell commandsGit repos (remote)
check_diff supportYesNoNoNo
check_list_files supportYesNoNoNo
Stash managementYesPartialNoPartial
Dependency resolutionYesNoNoNo
Batched executionYesNoNoNo

*prek itself is Rust but many hooks in the pre-commit ecosystem require Python environments to run.

See also: Benchmarks for reproducible performance numbers.

Come give it a whirl: Getting Started

Licensed under the MIT License. Maintained by @jdx and friends.