{ config, lib, pkgs, hookModule, ... }: let inherit (lib) boolToString concatStringsSep compare filterAttrs mapAttrsToList mkOption types remove ; inherit (pkgs) runCommand; inherit (pkgs.rustPlatform) cargoSetupHook; inherit (pkgs.stdenv) mkDerivation; cfg = config; install_stages = lib.unique (builtins.concatLists (lib.mapAttrsToList (_: h: h.stages) enabledHooks)); supportedHooksLib = import ./supported-hooks.nix { inherit lib; }; hookType = types.submodule hookModule; mergeExcludes = excludes: if excludes == [ ] then "^$" else "(${concatStringsSep "|" excludes})"; enabledHooks = filterAttrs (id: value: value.enable) cfg.hooks; enabledExtraPackages = builtins.concatLists (mapAttrsToList (_: value: value.extraPackages) enabledHooks); usingPrek = cfg.package.pname == "prek"; processedHooks = let sortedHooks = lib.toposort (a: b: builtins.elem b.id a.before || builtins.elem a.id b.after) (builtins.attrValues enabledHooks); in if sortedHooks ? result then builtins.map (value: value.raw) sortedHooks.result else let getIds = builtins.map (value: value.id); prettyPrintCycle = opts: cycle: lib.pipe cycle [ (builtins.map (hook: lib.nameValuePair hook.id { before = hook.before; after = hook.after; } )) lib.listToAttrs (lib.generators.toPretty opts) ]; in throw '' The hooks can't be sorted because of a cycle in the dependency graph: ${concatStringsSep " -> " (getIds sortedHooks.cycle)} which leads to a loop back to: ${concatStringsSep ", " (getIds sortedHooks.loops)} Try removing the conflicting hook ids from the `before` and `after` attributes of these hooks: ${prettyPrintCycle { indent = " "; } sortedHooks.cycle} ''; configFile = performAssertions ( runCommand "pre-commit-config.json" { buildInputs = [ pkgs.jq # needs to be an input so we regenerate the config and reinstall the hooks # when the package changes cfg.package ]; passAsFile = [ "rawJSON" ]; rawJSON = builtins.toJSON cfg.rawConfig; } '' { echo '# DO NOT MODIFY'; echo '# This file was generated by git-hooks.nix'; jq . <"$rawJSONPath" } >$out '' ); run = mkDerivation { name = "pre-commit-run"; src = cfg.rootSrc; buildInputs = [ cfg.gitPackage cfg.package ]; nativeBuildInputs = enabledExtraPackages ++ lib.optional (config.settings.rust.check.cargoDeps != null) cargoSetupHook; cargoDeps = config.settings.rust.check.cargoDeps; buildPhase = '' set +e # Set HOME to a temporary directory for pre-commit to create its cache files in. HOME=$(mktemp -d) ln -fs ${cfg.configFile} ${cfg.configPath} git init -q git add . git config --global user.email "you@example.com" git config --global user.name "Your Name" git commit -m "init" -q if [[ ${toString (compare cfg.installStages [ "manual" ])} -eq 0 ]] then echo "Running: $ ${cfg.package.pname} run --hook-stage manual --all-files" ${lib.getExe cfg.package} run -c ${cfg.configPath} --hook-stage manual --all-files else echo "Running: $ ${cfg.package.pname} run --all-files" ${lib.getExe cfg.package} run -c ${cfg.configPath} --all-files fi exitcode=$? git --no-pager diff --color # Derivations must produce an output mkdir $out [ $? -eq 0 ] && exit $exitcode ''; }; failedAssertions = builtins.map (x: x.message) (builtins.filter (x: !x.assertion) config.assertions); performAssertions = let formatAssertionMessage = message: let lines = lib.splitString "\n" message; in "- ${lib.concatStringsSep "\n " lines}"; in if failedAssertions != [ ] then throw '' Failed assertions: ${lib.concatStringsSep "\n" (builtins.map formatAssertionMessage failedAssertions)} '' else lib.trivial.showWarnings config.warnings; in { options = { enable = mkOption { type = types.bool; default = true; description = '' Whether to enable the pre-commit hooks module. When set to false, this disables the entire module. ''; }; install = { enable = mkOption { type = types.bool; default = true; description = '' Whether to enable automatic installation of pre-commit hooks. When set to false, hooks will not be installed into the git repository, but all other module functionality (like configuration generation) will still work. ''; }; }; package = mkOption { type = types.package; description = '' The `pre-commit` package to use. ''; default = pkgs.pre-commit; defaultText = lib.literalExpression "pkgs.pre-commit"; example = lib.literalExpression "pkgs.prek"; }; gitPackage = mkOption { type = types.package; description = '' The `git` package to use. ''; default = pkgs.gitMinimal; defaultText = lib.literalExpression "pkgs.gitMinimal"; }; tools = mkOption { type = types.lazyAttrsOf (types.nullOr types.package); description = '' Tool set from which `nix-pre-commit-hooks` will pick binaries. `nix-pre-commit-hooks` comes with its own set of packages for this purpose. ''; defaultText = lib.literalExpression '' git-hooks.nix-pkgs.callPackage tools-dot-nix { inherit (pkgs) system; } ''; }; enabledPackages = mkOption { type = types.listOf types.unspecified; description = '' All packages provided by hooks that are enabled. Useful for including into the developer environment. ''; default = lib.pipe config.hooks [ builtins.attrValues (lib.filter (hook: hook.enable)) (builtins.concatMap (hook: (lib.optional (hook.package != null) hook.package) ++ hook.extraPackages )) ]; }; hooks = mkOption { type = types.submodule { freeformType = types.attrsOf hookType; }; description = '' The hook definitions. You can both specify your own hooks here and you can enable predefined hooks. Example of enabling a predefined hook: ```nix hooks.nixpkgs-fmt.enable = true; ``` Example of a custom hook: ```nix hooks.my-tool = { enable = true; name = "my-tool"; description = "Run MyTool on all files in the project"; files = "\\.mtl$"; entry = "''${pkgs.my-tool}/bin/mytoolctl"; }; ``` The predefined hooks are: ${ concatStringsSep "\n" (lib.mapAttrsToList (hookName: hookConf: '' **`${hookName}`** ${hookConf.description} '') config.hooks) } ''; default = { }; }; run = mkOption { type = types.package; description = '' A derivation that tests whether the pre-commit hooks run cleanly on the entire project. ''; readOnly = false; default = run; defaultText = lib.literalExpression ""; }; shellHook = mkOption { type = types.str; description = '' A shell hook that sets up the git hooks in a development shell. Pass to `mkShell` as `shellHook` to use. ''; readOnly = true; }; installationScript = mkOption { type = types.str; description = '' A bash snippet that installs the git hooks in the current repository. ''; readOnly = true; }; configFile = mkOption { type = types.package; description = '' The pre-commit configuration file. ''; readOnly = true; default = configFile; defaultText = ""; }; configPath = lib.mkOption { type = types.str; default = ".pre-commit-config.yaml"; description = '' The path where to generate the pre-commit configuration file. This path is relative to the root of the project. By default, this is set to ".pre-commit-config.yaml", which is the standard location expected by pre-commit. ''; }; src = lib.mkOption { description = '' Root of the project. By default this will be filtered with the `gitignoreSource` function later, unless `rootSrc` is specified. If you use the `flakeModule`, the default is `self.outPath`; the whole flake sources. ''; type = lib.types.path; }; rootSrc = mkOption { type = types.path; description = '' The source of the project to be checked. This is used in the derivation that performs the check. If you use the `flakeModule`, the default is `self.outPath`; the whole flake sources. ''; defaultText = lib.literalExpression ''gitignoreSource config.src''; }; excludes = mkOption { type = types.listOf types.str; description = '' Exclude files that were matched by these patterns. ''; default = [ ]; }; default_stages = mkOption { type = supportedHooksLib.supportedHooksType; description = '' A configuration wide option for the stages property. Installs hooks to the defined stages. See [https://pre-commit.com/#confining-hooks-to-run-at-certain-stages](https://pre-commit.com/#confining-hooks-to-run-at-certain-stages). ''; default = [ "pre-commit" ]; }; rawConfig = mkOption { type = types.attrsOf types.unspecified; description = '' The raw configuration before writing to file. This option does not have an appropriate merge function. It is accessible in case you need to set an attribute that doesn't have an option. ''; internal = true; }; addGcRoot = mkOption { type = types.bool; default = true; description = '' Whether to add the generated pre-commit config to the garbage collector roots. This prevents Nix from garbage-collecting the tools used by hooks. ''; }; assertions = lib.mkOption { type = types.listOf types.unspecified; internal = true; default = [ ]; example = [{ assertion = false; message = "you can't enable this for that reason"; }]; description = '' This option allows modules to express conditions that must hold for the evaluation of the configuration to succeed, along with associated error messages for the user. ''; }; warnings = lib.mkOption { type = types.listOf types.str; internal = true; default = [ ]; example = [ "you should fix this or that" ]; description = '' This option allows modules to express warnings about the configuration. For example, `lib.mkRenamedOptionModule` uses this to display a warning message when a renamed option is used. ''; }; installStages = lib.mkOption { type = types.listOf (types.either types.str types.anything); description = '' The stages to install the hooks to. ''; default = install_stages; readOnly = true; }; }; config = lib.mkIf cfg.enable { # Hook removal notices should be defined here assertions = [ { assertion = !(cfg.hooks ? purty); message = "The `purty` hook has been removed because the project is unmaintained. Consider using `purs-tidy` instead."; } { assertion = usingPrek || (lib.all (hook: !(hook ? priority)) processedHooks); message = let hooksWithPriority = lib.filter (hook: hook ? priority) processedHooks; hookNames = lib.concatMapStringsSep ", " (hook: hook.id) hooksWithPriority; in '' The `priority` field is only supported when using prek as the package. The following hooks have priority defined: ${hookNames} ''; } ]; rawConfig = { repos = [ { repo = "local"; hooks = processedHooks; } ]; } // lib.optionalAttrs (cfg.excludes != [ ]) { exclude = mergeExcludes cfg.excludes; } // lib.optionalAttrs (cfg.default_stages != [ ]) { default_stages = cfg.default_stages; }; shellHook = '' ${cfg.installationScript} export PATH=${cfg.package}/bin:$PATH ''; installationScript = '' if ${boolToString cfg.install.enable}; then if ! ${cfg.gitPackage}/bin/git rev-parse --git-dir &> /dev/null; then echo 1>&2 "WARNING: git-hooks.nix: .git not found; skipping installation." else GIT_WC=`${lib.getExe cfg.gitPackage} rev-parse --show-toplevel` # These update procedures compare before they write, to avoid # filesystem churn. This improves performance with watch tools like lorri # and prevents installation loops by lorri. if ! readlink "''${GIT_WC}/${cfg.configPath}" >/dev/null \ || [[ $(readlink "''${GIT_WC}/${cfg.configPath}") != ${cfg.configFile} ]]; then echo 1>&2 "git-hooks.nix: updating $PWD repo" [ -L "''${GIT_WC}/${cfg.configPath}" ] && unlink "''${GIT_WC}/${cfg.configPath}" if [ -e "''${GIT_WC}/${cfg.configPath}" ]; then echo 1>&2 "git-hooks.nix: WARNING: Refusing to install because of an existing config at ${cfg.configPath}" echo 1>&2 "" echo 1>&2 " To migrate the existing config to a Nix configuration:" echo 1>&2 " 1. Translate the contents of ${cfg.configPath} into a Nix configuration." echo 1>&2 " See https://github.com/cachix/git-hooks.nix#getting-started" echo 1>&2 " 2. Remove ${cfg.configPath}" echo 1>&2 " 3. Add ${cfg.configPath} to .gitignore" else if ${boolToString cfg.addGcRoot}; then nix-store --add-root "''${GIT_WC}/${cfg.configPath}" --indirect --realise ${cfg.configFile} else ln -fs ${cfg.configFile} "''${GIT_WC}/${cfg.configPath}" fi # Remove any previously installed hooks (since pre-commit itself has no convergent design) hooks="${concatStringsSep " " (remove "manual" supportedHooksLib.supportedHooks )}" for hook in $hooks; do ${lib.getExe cfg.package} uninstall -t $hook done # Clear any user-configured core.hooksPath so the hook tool installs into the real hooks dir. ${lib.getExe cfg.gitPackage} config --local --unset-all core.hooksPath || true # Add hooks for configured stages (only) ... if [ ! -z "${concatStringsSep " " install_stages}" ]; then for stage in ${concatStringsSep " " install_stages}; do case $stage in manual) ;; # if you amend these switches please also review $hooks above commit | merge-commit | push) stage="pre-"$stage ${lib.getExe cfg.package} install -c ${cfg.configPath} -t $stage ;; ${concatStringsSep "|" supportedHooksLib.supportedHooks}) ${lib.getExe cfg.package} install -c ${cfg.configPath} -t $stage ;; *) echo 1>&2 "ERROR: git-hooks.nix: either $stage is not a valid stage or git-hooks.nix doesn't yet support it." exit 1 ;; esac done # ... or default 'pre-commit' hook else ${lib.getExe cfg.package} install -c ${cfg.configPath} fi # Fetch the absolute path to the git common directory. This will normally point to $GIT_WC/.git. common_dir=''$(${cfg.gitPackage}/bin/git rev-parse --path-format=absolute --git-common-dir) # Convert the absolute path to a path relative to the toplevel working directory. common_dir=''${common_dir#''$GIT_WC/} ${lib.getExe cfg.gitPackage} config --local core.hooksPath "''$common_dir/hooks" fi fi fi fi ''; }; }