A small, fast, drop-in replacement for git-hooks.nix
README.md

Development takes place on Tangled, however, I do maintain a GitHub mirror. Please make issues and PRs to the Tangled repository.

nixhooks #

A small, fast, drop-in replacement for git-hooks.nix. No flake required, no flake-parts, no systems, no Python, no slop, just plain ol' Nix and Bash.

Usage (non-flake) #

{ pkgs ? import <nixpkgs> { } }:

let
  nixhooks = import (fetchTarball "https://next.tangled.org/xrpc/sh.tangled.repo.archive?repo=at%3A%2F%2Fdid%3Aplc%3Ayaryxzbwui6ejiegzysy6ipp%2Fsh.tangled.repo%2Fnixhooks&ref=main&format=tar.gz") { inherit pkgs; };
in
nixhooks.mkHooks {
  hooks = {
    shellcheck = {
      entry = "${pkgs.shellcheck}/bin/shellcheck";
      files = "\\.sh$";
      stages = [ "pre-commit" "pre-push" ];
    };

    nixfmt-check = {
      entry = "${pkgs.nixfmt}/bin/nixfmt";
      args = [ "--check" ];
      files = "\\.nix$";
    };
  };
}
$ nix-build -A packages.install-hooks && ./result/bin/install-hooks

Or, install hooks automatically on shell entry with a shell.nix:

{ pkgs ? import <nixpkgs> { } }:

let
  hooks = import ./default.nix { inherit pkgs; };
in
hooks.wrapShell (pkgs.mkShell {
  packages = [ pkgs.jq ];
})

wrapShell keeps the shell's inputs and appends to its existing shellHook. If you'd rather splice it in yourself, hooks.shellHook is the bare snippet.

Usage (flake) #

nixhooks.lib.<system> is mkHooks/presets built from nixhooks' own pinned nixpkgs for that system.

{
  inputs.nixhooks.url = "git+https://tangled.org/poacher.dev/nixhooks";
  outputs = { self, nixpkgs, nixhooks }: let
    pkgs = nixpkgs.legacyPackages.x86_64-linux;
    hooks = nixhooks.lib.x86_64-linux.mkHooks {
      hooks = {
        shellcheck = {
          entry = "${pkgs.shellcheck}/bin/shellcheck";
          files = "\\.sh$";
        };
      };
    };
  in {
    packages.x86_64-linux = hooks.packages;
    apps.x86_64-linux = hooks.apps;
    devShells.x86_64-linux.default = hooks.wrapShell (pkgs.mkShell {
      packages = [ pkgs.jq ];
    });
  };
}
$ nix run .#install-hooks

lib.withHooks #

withHooks is sugar over mkHooks for flakes. Pass it your hook config alongside your regular flake outputs and it merges the generated apps/packages and wires install-hooks into devShells.<system>.default

Hooks are passed as a hooks argument keyed by system, kept separate from your regular flake outputs. Each system's attrset may also carry its own settings.

{
  inputs.nixhooks.url = "git+https://tangled.org/poacher.dev/nixhooks";
  outputs = { self, nixpkgs, nixhooks }: let
    pkgs = nixpkgs.legacyPackages.x86_64-linux;
  in nixhooks.lib.withHooks {
    hooks.x86_64-linux = {
      inherit (nixhooks.lib.x86_64-linux.presets) alejandra statix;
      settings = {
        parallel = true;
        tangled = {
          enable = true;
          attr = "run-hooks";
          flake = true;
        };
      };
    };

    packages.x86_64-linux = { /* ... */ };
    apps.x86_64-linux = { /* ... */ };
    devShells.x86_64-linux.default = pkgs.mkShell {
      packages = [ pkgs.jq ];
    };
  };
}

Migrating #

  • The top-level nixhooks = { hooks = ...; settings = ...; } argument to withHooks has been removed. Use hooks.<system> instead, with settings at hooks.<system>.settings.
  • mkHooks no longer exposes its derivations at the top level; they live under packages (e.g. hooks.install-hooks is now hooks.packages.install-hooks). For a shellHook, prefer hooks.shellHook or hooks.wrapShell. Non-flake CI pipelines generated before this change run nix-build -A run-hooks, so regenerate them.

Hook spec #

hooks.<name> = {
  enable = true;                     # default true; set false to disable without deleting the entry
  entry = "/nix/store/.../bin/tool"; # the command to run, exclusive with script
  args = [ ];                        # extra args passed before matched filenames
  files = ".*";                      # ERE regex (bash [[ =~ ]]) tested against each candidate path
  exclude = "^$";                    # ERE regex; "^$" (default) excludes nothing
  stages = [ "pre-commit" ];         # subset of [ "pre-commit" "pre-push" "commit-msg" ]
  pass_filenames = true;             # append matched files as trailing args
  always_run = false;                # run once with no file filtering/passing 
  path = [ ];                        # packages whose bin/ dirs are prepended to PATH for entry
  serial = true;                     # if false this hook will be run in parallel; see "Parallel execution" below
  required = true;                   # if false a failing hook only warns instead of blocking the git action
  fallback = "tool";                 # defaults to entry's filename, looked up on PATH when entry isn't found
  script = null;                     # bash body run in place of entry; see below
};

<name> must match [A-Za-z0-9_.-]+

A hook fails if entry exits nonzero, or if neither entry nor fallback can be found. A failing required hook blocks the commit/push; any other hook just prints a warning.

path is for tools that internally dispatch to a sibling binary via PATH (e.g. cargo finding cargo-clippy) -- entry itself is always invoked by absolute path regardless of path.

script is for hooks that need a bit of shell around their tool. It's embedded in the generated hook script and run like a standalone bash script with args and the matched files as "$@". You may find tools with nixhooks_tool, which prints the store binary if it exists and otherwise looks the name up on PATH:

gofmt = {
  script = ''
    gofmt="$(nixhooks_tool ${pkgs.go}/bin/gofmt gofmt)" || exit
    unformatted="$("$gofmt" -l "$@")" || exit
    [ -z "$unformatted" ] || { echo "$unformatted"; exit 1; }
  '';
  files = "\\.go$";
};

A commit-msg-staged hook receives the path to the temporary file containing the commit message as its sole file. files/exclude/ pass_filenames apply to that single path the same way they apply to real filenames in pre-commit/pre-push.

Presets #

presets is a small built-in catalog of common tool configs. These are functors which accept either no arguments, or allow for their options to be overriden in an attrSet.

nixhooks.mkHooks {
  hooks = {
    shellcheck = nixhooksLib.presets.shellcheck;
    alejandra = nixhooksLib.presets.alejandra { stages = [ "pre-push" ]; };
  };
}

See lib/presets.nix for the full list of presets.

Parallel execution #

By default every hook runs sequentially. Passing settings.parallel = true to mkHooks enables a two-phase model where every hook not marked serial runs concurrently, then the remaining hooks run sequentially. Note that parallelism can actually decrease your hook execution speed if its already fast. Only use it when needed.

nixhooks.mkHooks {
  hooks = {inherit (nixhooksLib.presets) shellcheck alejandra statix;};
  settings.parallel = true;
}

Output from the concurrent batch is buffered per hook and flushed once the entire batch finishes in the declared order.

Only mark a hook serial = false if it doesn't mutate the files it's matched against, or if it's provably safe to race against every other serial = false hook in the same stage.

Non-Nix contributors #

The hooks install-hooks sets up live in the Nix store, so contributors without Nix can't use them. Enabling settings.portable generates a copy you commit to the repo instead:

nixhooks.mkHooks {
  hooks = { /* ... */ };
  settings.portable = {
    enable = true;
    dir = ".nixhooks"; # relative to the repo root
  };
}
$ nix run .#gen-portable-hooks
gen-portable-hooks: wrote .nixhooks/commit-msg
gen-portable-hooks: wrote .nixhooks/pre-commit
gen-portable-hooks: wrote .nixhooks/pre-push

Contributors without Nix then enable them with:

$ git config core.hooksPath .nixhooks

CI/CD #

nixhooks can also autogenerate CI workflows based on your selected hooks.

Hooks staged only for commit-msg (e.g. the commitlint preset) are excluded from the generated CI pipeline's run-hooks invocation as CI has no commit message to check. A hook declaring multiple stages including commit-msg still runs in CI for its other stage(s).

Tangled pipelines #

nixhooks.mkHooks {
  hooks = { /* ... */ };
  settings.tangled = {
    enable = true;
    attr = "run-hooks";
    flake = true;
    engine = "microvm";
    when = [
      {
        event = ["push" "pull_request"];
        branch = ["main"];
      }
    ];
  };
}
$ nix-build -A packages.gen-tangled-pipeline && ./result/bin/gen-tangled-pipeline
gen-tangled-pipeline: wrote .tangled/workflows/hooks.yml

This writes a .tangled/workflows/hooks.yml in the same manner as install-hooks which you commit to the repo. Note that this isn't wired into shellHook like install-hooks, so you'll need to trigger it manually.

GitHub Actions #

nixhooks.mkHooks {
  hooks = { /* ... */ };
  settings.githubActions = {
    enable = true;
    attr = "run-hooks";
    flake = true;
    runsOn = "ubuntu-latest";
    cache = true; # nix-community/cache-nix-action
    on = {
      push = { branches = [ "main" ]; };
      pull_request = { branches = [ "main" ]; };
    };
  };
}
$ nix-build -A packages.gen-github-actions-workflow && ./result/bin/gen-github-actions-workflow
gen-github-actions-workflow: wrote .github/workflows/hooks.yml

Note that I don't actively used GitHub. If you find any bugs then please open an issue.

Benchmarks #

https://tangled.org/poacher.dev/nixhooks-benchmark