Using lefthook with Nix
In my recent attempts to modernize my CI pipelines, I’ve experimented with and subsequently let go of pre-commit. The reason why I didn’t stick with pre-commit is that pre-commit drags in several software runtimes and toolchains when building it from scratch. When Nix fails to build something from nixpkgs, it’s an omen.
Alternatives I’m aware of:
- lefthook: A generic tool written in Go; defines hooks in a lefthook.yml file.
- husky: A generic tool written in JavaScript; enough said.
- cargo-husky: A tool specialized to Rust/Cargo; very nice, but not generic enough.
I initially disregarded lefthook because of its special lefthook.yml.
Since every alternative I’m aware of is worse in some way, I’m back to trying it.
tl;dr ¶
- Lefthook’s Go TUI library probes your terminal with OSC escape sequences
- This produces garbage output and makes your shell janky afterwards
- Setting
TERM=dumbvia aLEFTHOOK_BINwrapper fixes it - Using lefthook-nix instead of plain lefthook gives you automatic hook installation
- It also gives you reproducible CI checks from the same config, and a declarative Nix-native setup
The problem with lefthook in modern terminals ¶
Lefthook uses charmbracelet/lipgloss for its terminal UI. This library sends OSC 10/11 escape sequences to detect your terminal’s color scheme. The terminal responds with escape sequences written to stdin. When the hook finishes, these responses are still in the input buffer. Your shell tries to interpret them as commands, producing garbled output like:
^[]10;rgb:dddd/dddd/dddd^[\^[[34;1R^[]11;rgb:0000/0000/0000^[\^[[34;1R
This happens in kitty, alacritty, and other modern terminal emulators. It will make your shell janky requiring multiple Ctrl+C presses to recover.
Neither colors: false nor no_tty: true in the lefthook config prevent this – the library sends
the queries before reading any config.
The simple fix that worked for me: run lefthook with TERM=dumb. This prevents the library from
probing the terminal entirely. As a bonus, it’s noticeably faster since it skips the TUI animation
rendering.
Why lefthook-nix over plain lefthook ¶
lefthook-nix wraps lefthook in a Nix-native interface. Compared to managing lefthook manually:
- Automatic hook installation. The devshell’s
shellHookinstalls the git hooks when you enter the shell. Nolefthook installstep, no forgetting to set it up after cloning.cargo-huskydoes something very similar, but this only benefits Rust projects, and I sorely miss it. - Same config for dev and CI. The
runfunction returns both ashellHookfor your devshell and a derivation you can use as achecksoutput. Your CI runs the exact same hooks withnix flake check. - Declarative config in Nix. No
lefthook.ymlto maintain by hand. The YAML is generated from your Nix expression and stays in sync automatically. - Pinned and reproducible. The lefthook version, the formatter, and all dependencies are pinned through your flake inputs. Every developer gets the same versions.
A minimal flake.nix ¶
Here’s a complete, self-contained flake.nix that sets up lefthook with treefmt
as a pre-commit hook:
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
treefmt-nix.url = "github:numtide/treefmt-nix";
treefmt-nix.inputs.nixpkgs.follows = "nixpkgs";
lefthook-nix.url = "github:sudosubin/lefthook.nix";
lefthook-nix.inputs.nixpkgs.follows = "nixpkgs";
};
outputs =
{ self, nixpkgs, treefmt-nix, lefthook-nix, ... }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
lib = pkgs.lib;
treefmt = treefmt-nix.lib.evalModule pkgs {
projectRootFile = "flake.nix";
programs.nixfmt.enable = true;
};
lefthook = lefthook-nix.lib.${system}.run {
src = self;
config = {
pre-commit.commands.treefmt = {
run = "treefmt --fail-on-change --no-cache {staged_files}";
};
};
};
in
{
formatter.${system} = treefmt.config.build.wrapper;
checks.${system} = {
formatting = treefmt.config.build.check self;
hooks = lefthook;
};
devShells.${system}.default = pkgs.mkShell {
inherit (lefthook) shellHook;
packages = [ treefmt.config.build.wrapper ];
LEFTHOOK_BIN = toString (
pkgs.writeShellScript "lefthook-dumb-term" ''
exec env TERM=dumb ${lib.getExe pkgs.lefthook} "$@"
''
);
};
};
}
The key pieces:
treefmt-nixconfigures treefmt with your formatters (here, justnixfmt).lefthook-nixdefines the pre-commit hook. The{staged_files}placeholder is replaced by lefthook with the list of staged files at commit time.--fail-on-changemakes treefmt exit non-zero when files need formatting, so the commit is actually blocked. Without it, treefmt silently reformats and exits successfully – your commit goes through with the unformatted version staged and the formatted version left as an unstaged change.--no-cacheensures treefmt checks the staged files fresh rather than relying on its cache.LEFTHOOK_BINwraps lefthook withTERM=dumbto prevent the terminal probing garbage. The pre-commit hook checks this environment variable first, so it uses our wrapper while leaving your normal terminal unaffected.
Using it ¶
Add a .envrc for direnv:
use flake
Enter the shell (or let direnv do it). The shellHook installs the git hooks
automatically. Now any git commit runs treefmt on your staged files:
$ git commit -m "my changes"
lefthook v2.1.1 hook: pre-commit
treefmt >
traversed 3 files
emitted 3 files for processing
formatted 0 files (0 changed) in 2.01s
summary: (done in 2.03 seconds)
treefmt (2.02 seconds)
[main abc1234] my changes
1 file changed, 5 insertions(+)
If files need formatting, the commit is blocked:
Error: unexpected changes detected, --fail-on-change is enabled
summary: (done in 2.05 seconds)
treefmt (2.04 seconds)
The formatted files are left in your working tree. Stage them and commit again.
CI gets the same checks for free with nix flake check.
Keeping hooks in sync with your config ¶
One gotcha: the lefthook.yml and git hooks are generated when you enter the
devshell. If you change the hook config in flake.nix, you need to run
direnv reload to regenerate them. This is easy to forget.
Using treefmt from PATH (rather than hardcoding a nix store path with
lib.getExe) reduces this problem. Changes to treefmt flags still require a
reload, but changing which formatters are enabled in your treefmt config doesn’t
– the treefmt wrapper on PATH already points to the current config.
If you use direnv, you can add watch_file to your .envrc to auto-reload when
relevant files change:
watch_file flake.nix
use flake
For larger projects, this gets noisy – every change to flake.nix triggers a
reload, even if it’s unrelated to the devshell. A better approach is to split
your flake into separate modules using
flake-parts and the dendritic pattern
(LINK_TO_ARTICLE). This lets you watch_file only the specific module files
that affect your devshell:
watch_file modules/devshell.nix
watch_file modules/treefmt.nix
use flake
Now editing your hook or formatter config triggers an automatic direnv reload,
keeping the generated lefthook.yml in sync without manual intervention. Changes
to unrelated modules (NixOS configurations, packages, etc.) don’t cause
unnecessary rebuilds of your devshell.