Git config-based hooks with hk-nix
hk is a fast, modern git hook manager and project linter configured in Pkl.
hk-nix is to hk what lefthook-nix is to lefthook: you install hk with
Nixpkgs, declare your hooks in Nix, pin every tool with Nix, and the hooks install themselves
whenever you enter the devshell. It is the same stack I previously built with
lefthook, with a nicer configuration language and one fewer workaround.
Synergies ¶
Nix + direnv: You only ever need to install these two manually: Nix being the package manager, and direnv being the thing that activates the Nix devshell. All other tooling is derived, pinned and installed for you.
Nix + hk: every command a hook runs is referenced by an absolute /nix/store path, so the exact
tool that runs locally runs in CI. Adding a hook in one place adds a hook everywhere; nix flake check runs the same hooks over the whole tree.
hk + direnv: the git hooks install automatically when you cd into the project directory. No
manual hk install step, no “remember to set it up after cloning”. Clone the repo, enter it and
direnv allow (direnv will remind you). When the hooks change, direnv reinstalls them for you.
Getting started ¶
Here is a complete, self-contained flake.nix using flake-parts that installs hk and
runs nixfmt as a pre-commit hook. Starting from flake-parts makes it easy to split the
configuration into modules as the project grows.
# flake.nix
{
inputs = {
nixpkgs.url = "https://nixos.org/channels/nixpkgs-unstable/nixexprs.tar.xz";
flake-parts.url = "github:hercules-ci/flake-parts";
hk-nix.url = "github:nix-tools/hk-nix";
hk-nix.inputs.nixpkgs.follows = "nixpkgs";
};
outputs =
inputs@{ flake-parts, hk-nix, ... }:
flake-parts.lib.mkFlake { inherit inputs; } {
systems = [ "x86_64-linux" "aarch64-darwin" ];
imports = [ hk-nix.flakeModules.default ];
perSystem =
{ config, pkgs, lib, ... }:
{
hk-nix.settings.hooks."pre-commit" = {
fix = true;
stash = "git";
steps.nixfmt = {
glob = "**/*.nix"; # "*.nix" would incidentally do the same
check = "${lib.getExe pkgs.nixfmt} --check {{files}}";
fix = "${lib.getExe pkgs.nixfmt} {{files}}";
};
};
devShells.default = pkgs.mkShell {
packages = [ config.hk-nix.package pkgs.git ];
shellHook = config.hk-nix.shellHook;
};
};
};
}
Activate it for direnv:
$ echo "use flake" > .envrc
$ direnv allow
Entering the devshell via direnv allow or nix develop runs config.hk-nix.shellHook, which
writes the generated hk.pkl and installs the git hooks. Now any git commit runs nixfmt over your
staged *.nix files; with fix = true, hk formats them in place, and stash = "git" keeps unstaged
changes out of the way while it does. You may want to .gitignore the generated symlink:
/hk.pkl
Importing hk-nix.flakeModules.default also sets checks.hk, so CI gets the same hook, read-only
over every file, for free:
$ nix flake check
What hk-nix adds on top of hk ¶
hk on its own configures hooks; it does not install anything by itself. hk-nix closes that gap:
- It installs hk itself. The hk binary is pinned through your flake and put on the devshell
PATH. Nobody installs hk out of band, and everyone runs the same version. - It installs every tool your hooks reference. Whether a step is a builtin or a custom
check/fixstring, the formatter, linter, or checker it names is pinned from Nixpkgs and referenced by/nix/storepath. There is nothing toapt installornpm install; the same binaries run locally and in CI vianix flake check. - It keeps the hooks installed and recent. Because installation happens in the devshell
shellHook, the hooks are (re)installed every time direnv (re)loads the environment: on a freshly cloned repo on a new machine, and again whenever the hooks themselves change.
For reinstalls, direnv needs to know where your hooks are defined in .envrc, e.g.:
watch_file nix/hooks.nix
watch_file flake.nix
use flake
Editing a hook now triggers a direnv reload, which regenerates hk.pkl and reinstalls the hooks.
Built-in linters ¶
hk ships 140+ pre-configured linters, formatters, and checkers as builtins:
ready-made step definitions (globs, commands, sensible defaults) that you reference by name instead
of hand-writing. hk-nix exposes each one as config.hk-nix.builtins.<name>:
perSystem =
{ config, ... }:
let hk-builtins = config.hk-nix.builtins; in
{
hk-nix.settings.hooks."pre-commit" = {
fix = true;
stash = "git";
steps = {
gitleaks.builtin = hk-builtins.gitleaks;
check_merge_conflict.builtin = hk-builtins.check_merge_conflict;
actionlint.builtin = hk-builtins.actionlint;
};
};
};
(So far, 123 out of 143 hooks are vendored via Nixpkgs.)
A builtin identifies the tool; hk-nix pins it from Nixpkgs and injects it into the step’s PATH by
absolute store path. Two independent knobs adjust that: .override repins the tool, and every key
besides builtin merges into the generated hk step. Here both are used at once, taking gitleaks
from a flake input of your own rather than Nixpkgs, and narrowing the step to one subtree:
steps.gitleaks = {
builtin = hk-builtins.gitleaks.override { package = inputs'.my-tools.packages.gitleaks; };
glob = "src/**/*";
};
Leave formatting to treefmt ¶
hk has formatting builtins, and you could stack up one per language. I don’t, because formatting is
the job treefmt exists to own, and treefmt-nix already wires it into nix fmt and a checks.treefmt. So treefmt stays the single source of truth for formatting, and hk runs
it as one step:
imports = [
hk-nix.flakeModules.default
inputs.treefmt-nix.flakeModule
];
perSystem =
{ config, ... }:
let treefmt-bin = "${config.treefmt.build.wrapper}/bin/treefmt"; in
{
treefmt = {
projectRootFile = "flake.nix";
programs.nixfmt.enable = true;
programs.prettier.enable = true; # add formatters for every language you use
};
# --fail-on-change turns "would reformat" into a non-zero exit so the commit is
# blocked; --no-cache avoids a writable-cache requirement inside the nix flake
# check sandbox.
hk-nix.settings.hooks."pre-commit".steps.treefmt = {
check = "${treefmt-bin} --fail-on-change --no-cache {{files}}";
fix = "${treefmt-bin} --no-cache {{files}}";
};
};
config.treefmt.build.wrapper is a treefmt binary with your generated configuration baked in,
referenced by absolute store path like every other tool. {{files}} is the staged files; treefmt
applies whichever formatter matches each one and ignores the rest. That leaves hk the builtins
treefmt cannot express, because they analyze or gate rather than rewrite.
Config-based hooks ¶
hk-nix defaults to hk’s support for config-based hooks, a mechanism introduced in git 2.54.
Historically a git hook could only be an executable script in .git/hooks/ (or in another directory
named by core.hooksPath). Sharing hooks across repositories meant copying scripts around or
leaning on a third-party manager, and a repository could effectively run only one script per event.
So a hook manager’s task, historically, was also to keep track of multiple scripts per event.
Git 2.54 lets you define hooks in git configuration instead, per-repository (.git/config),
per-user (~/.gitconfig), or system-wide (/etc/gitconfig). A hook is declared with a
hook.<name>.command and the event it fires on:
[hook "linter"]
event = pre-commit
command = ~/bin/linter --cpp20
You can configure several hooks for the same event, and Git runs them in the order it encounters their configuration:
[hook "linter"]
event = pre-commit
command = ~/bin/linter --cpp20
[hook "no-leaks"]
event = pre-commit
command = ~/bin/leak-detector
Any traditional script in $GIT_DIR/hooks still runs, and it runs last, so existing hooks are
unaffected. git hook list shows the configured hooks and where each one comes from, and an
individual hook can be turned off without deleting its configuration:
[hook "linter"]
enabled = false
For hk-nix this matters because installation writes hk’s entry into .git/config rather than
dropping scripts into .git/hooks, leaving that directory untouched. hk-nix always installs the
config-based way (it never passes --legacy), and prepends a recent git to PATH so the
config-based path is actually taken.