diff --git a/.gitignore b/.gitignore index 9c88c4a..b768169 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .pre-commit-config.yaml +lefthook.yml .direnv result -template/flake.lock \ No newline at end of file +template/flake.lock diff --git a/README.md b/README.md index a2f539c..1e0d73b 100644 --- a/README.md +++ b/README.md @@ -66,11 +66,14 @@ outputs = { self, nixpkgs, repo-lib, ... }: `mkRepo` generates: - `devShells.${system}.default` -- `checks.${system}.pre-commit-check` +- `checks.${system}.hook-check` +- `checks.${system}.lefthook-check` - `formatter.${system}` - `packages.${system}.release` when `config.release != null` - merged `packages` and `apps` from `perSystem` +Checks are installed through `lefthook`, with `pre-commit` and `pre-push` commands configured to run in parallel. + ## Tool banners Tools are declared once. Package-backed tools are added to the shell automatically, and both package-backed and command-backed tools are rendered in the startup banner. diff --git a/flake.lock b/flake.lock index 73bcd98..b48d1cd 100644 --- a/flake.lock +++ b/flake.lock @@ -1,79 +1,26 @@ { "nodes": { - "flake-compat": { - "flake": false, - "locked": { - "lastModified": 1767039857, - "narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=", - "owner": "NixOS", - "repo": "flake-compat", - "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab", - "type": "github" - }, - "original": { - "owner": "NixOS", - "repo": "flake-compat", - "type": "github" - } - }, - "git-hooks": { - "inputs": { - "flake-compat": "flake-compat", - "gitignore": "gitignore", - "nixpkgs": "nixpkgs" - }, - "locked": { - "lastModified": 1772024342, - "narHash": "sha256-+eXlIc4/7dE6EcPs9a2DaSY3fTA9AE526hGqkNID3Wg=", - "owner": "cachix", - "repo": "git-hooks.nix", - "rev": "6e34e97ed9788b17796ee43ccdbaf871a5c2b476", - "type": "github" - }, - "original": { - "owner": "cachix", - "repo": "git-hooks.nix", - "type": "github" - } - }, - "gitignore": { + "lefthook-nix": { "inputs": { "nixpkgs": [ - "git-hooks", "nixpkgs" ] }, "locked": { - "lastModified": 1709087332, - "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", - "owner": "hercules-ci", - "repo": "gitignore.nix", - "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "lastModified": 1770377107, + "narHash": "sha256-/QEXSDeAo5RK81PtM0yDhmt9k3v1/pse/jsrT1yXNhU=", + "owner": "sudosubin", + "repo": "lefthook.nix", + "rev": "9cdaf7ce95ae77cbabc5b556bdd35d3cf0b849f5", "type": "github" }, "original": { - "owner": "hercules-ci", - "repo": "gitignore.nix", + "owner": "sudosubin", + "repo": "lefthook.nix", "type": "github" } }, "nixpkgs": { - "locked": { - "lastModified": 1770073757, - "narHash": "sha256-Vy+G+F+3E/Tl+GMNgiHl9Pah2DgShmIUBJXmbiQPHbI=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "47472570b1e607482890801aeaf29bfb749884f6", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixpkgs-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "nixpkgs_2": { "locked": { "lastModified": 1772542754, "narHash": "sha256-WGV2hy+VIeQsYXpsLjdr4GvHv5eECMISX1zKLTedhdg=", @@ -89,7 +36,7 @@ "type": "github" } }, - "nixpkgs_3": { + "nixpkgs_2": { "locked": { "lastModified": 1770107345, "narHash": "sha256-tbS0Ebx2PiA1FRW8mt8oejR0qMXmziJmPaU1d4kYY9g=", @@ -107,14 +54,14 @@ }, "root": { "inputs": { - "git-hooks": "git-hooks", - "nixpkgs": "nixpkgs_2", + "lefthook-nix": "lefthook-nix", + "nixpkgs": "nixpkgs", "treefmt-nix": "treefmt-nix" } }, "treefmt-nix": { "inputs": { - "nixpkgs": "nixpkgs_3" + "nixpkgs": "nixpkgs_2" }, "locked": { "lastModified": 1770228511, diff --git a/flake.nix b/flake.nix index 240a5d0..5587636 100644 --- a/flake.nix +++ b/flake.nix @@ -4,7 +4,8 @@ inputs = { nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; - git-hooks.url = "github:cachix/git-hooks.nix"; + lefthook-nix.url = "github:sudosubin/lefthook.nix"; + lefthook-nix.inputs.nixpkgs.follows = "nixpkgs"; treefmt-nix.url = "github:numtide/treefmt-nix"; }; @@ -13,13 +14,14 @@ self, nixpkgs, treefmt-nix, - git-hooks, + lefthook-nix, ... }: let lib = nixpkgs.lib; repoLib = import ./packages/repo-lib/lib.nix { - inherit nixpkgs treefmt-nix git-hooks; + inherit nixpkgs treefmt-nix; + lefthookNix = lefthook-nix; releaseScriptPath = ./packages/release/release.sh; shellHookTemplatePath = ./packages/repo-lib/shell-hook.sh; }; @@ -94,6 +96,7 @@ nativeBuildInputs = with pkgs; [ bash git + nix gnused coreutils gnugrep diff --git a/packages/release/release.nix b/packages/release/release.nix index 3aa8df3..be985b3 100644 --- a/packages/release/release.nix +++ b/packages/release/release.nix @@ -1,7 +1,7 @@ { nixpkgs, treefmt-nix, - git-hooks, + lefthookNix, releaseScriptPath ? ./release.sh, shellHookTemplatePath ? ../repo-lib/shell-hook.sh, }: @@ -9,7 +9,7 @@ import ../repo-lib/lib.nix { inherit nixpkgs treefmt-nix - git-hooks + lefthookNix releaseScriptPath shellHookTemplatePath ; diff --git a/packages/repo-lib/lib.nix b/packages/repo-lib/lib.nix index cfbf4f8..6db4f96 100644 --- a/packages/repo-lib/lib.nix +++ b/packages/repo-lib/lib.nix @@ -1,7 +1,7 @@ { nixpkgs, treefmt-nix, - git-hooks, + lefthookNix, releaseScriptPath, shellHookTemplatePath, }: @@ -159,6 +159,88 @@ let stages = [ check.stage ]; }; + normalizeHookStage = + hookName: stage: + if + builtins.elem stage [ + "pre-commit" + "pre-push" + "commit-msg" + ] + then + stage + else + throw "repo-lib: hook '${hookName}' has unsupported stage '${stage}' for lefthook"; + + hookStageFileArgs = + stage: passFilenames: + if !passFilenames then + "" + else if stage == "pre-commit" then + " {staged_files}" + else if stage == "pre-push" then + " {push_files}" + else if stage == "commit-msg" then + " {1}" + else + throw "repo-lib: unsupported lefthook stage '${stage}'"; + + hookToLefthookConfig = + name: hook: + let + supportedFields = [ + "description" + "enable" + "entry" + "name" + "package" + "pass_filenames" + "stages" + ]; + unsupportedFields = builtins.filter (field: !(builtins.elem field supportedFields)) ( + builtins.attrNames hook + ); + stages = builtins.map (stage: normalizeHookStage name stage) (hook.stages or [ "pre-commit" ]); + passFilenames = hook.pass_filenames or false; + in + if unsupportedFields != [ ] then + throw '' + repo-lib: hook '${name}' uses unsupported fields for lefthook: ${lib.concatStringsSep ", " unsupportedFields} + '' + else if !(hook ? entry) then + throw "repo-lib: hook '${name}' is missing 'entry'" + else + lib.foldl' lib.recursiveUpdate { } ( + builtins.map ( + stage: + lib.setAttrByPath [ stage "commands" name ] { + run = "${hook.entry}${hookStageFileArgs stage passFilenames}"; + } + ) stages + ); + + hookStages = + hooks: + lib.unique ( + [ + "pre-commit" + "commit-msg" + ] + ++ lib.concatMap (hook: hook.stages or [ "pre-commit" ]) (builtins.attrValues hooks) + ); + + parallelHookStageConfig = + stage: + if + builtins.elem stage [ + "pre-commit" + "pre-push" + ] + then + lib.setAttrByPath [ stage "parallel" ] true + else + { }; + normalizeReleaseStep = step: if step ? writeFile then @@ -277,7 +359,7 @@ let buildShellHook = { - preCommitShellHook, + hooksShellHook, shellEnvScript, bootstrap, shellBannerScript, @@ -289,7 +371,7 @@ let in builtins.replaceStrings [ - "\${pre-commit-check.shellHook}" + "@HOOKS_SHELL_HOOK@" "@TOOL_LABEL_WIDTH@" "@SHELL_ENV_SCRIPT@" "@BOOTSTRAP@" @@ -297,7 +379,7 @@ let "@EXTRA_SHELL_TEXT@" ] [ - preCommitShellHook + hooksShellHook (toString toolLabelWidth) shellEnvScript bootstrap @@ -345,23 +427,28 @@ let normalizedChecks = lib.mapAttrs (name: check: normalizeCheck pkgs name check) checkSpecs; hooks = mergeUniqueAttrs "hook" rawHookEntries normalizedChecks; - - pre-commit-check = git-hooks.lib.${system}.run { + lefthookCheck = lefthookNix.lib.${system}.run { inherit src; - hooks = { - treefmt = { - enable = true; - entry = "${treefmtEval.config.build.wrapper}/bin/treefmt --ci"; - pass_filenames = true; - }; - gitlint.enable = true; - gitleaks = { - enable = true; - entry = "${pkgs.gitleaks}/bin/gitleaks protect --staged"; - pass_filenames = false; - }; - } - // hooks; + config = lib.foldl' lib.recursiveUpdate { } ( + [ + (parallelHookStageConfig "pre-commit") + (lib.setAttrByPath [ "pre-commit" "commands" "treefmt" ] { + run = "${treefmtEval.config.build.wrapper}/bin/treefmt --ci {staged_files}"; + }) + (lib.setAttrByPath [ "pre-commit" "commands" "gitleaks" ] { + run = "${pkgs.gitleaks}/bin/gitleaks protect --staged"; + }) + (lib.setAttrByPath [ "commit-msg" "commands" "gitlint" ] { + run = "${pkgs.gitlint}/bin/gitlint --staged --msg-filename {1}"; + }) + ] + ++ builtins.map parallelHookStageConfig (hookStages hooks) + ++ lib.mapAttrsToList hookToLefthookConfig hooks + ); + }; + selectedCheckOutputs = { + hook-check = lefthookCheck; + lefthook-check = lefthookCheck; }; toolNames = builtins.map (tool: tool.name) tools; @@ -469,19 +556,21 @@ let ''; in { - inherit pre-commit-check; + checks = selectedCheckOutputs; formatter = treefmtEval.config.build.wrapper; shell = pkgs.mkShell { - packages = lib.unique (selectedStandardPackages ++ extraPackages ++ toolPackages); - buildInputs = pre-commit-check.enabledPackages; + packages = lib.unique ( + selectedStandardPackages ++ extraPackages ++ toolPackages ++ [ pkgs.lefthook ] + ); shellHook = buildShellHook { - preCommitShellHook = pre-commit-check.shellHook; + hooksShellHook = lefthookCheck.shellHook; inherit toolLabelWidth shellEnvScript shellBannerScript; bootstrap = shellConfig.bootstrap; extraShellText = shellConfig.extraShellText; }; }; - }; + } + // selectedCheckOutputs; in rec { systems = { @@ -762,9 +851,7 @@ rec { default = systemResults.${system}.env.shell; }); - checks = lib.genAttrs systems (system: { - inherit (systemResults.${system}.env) pre-commit-check; - }); + checks = lib.genAttrs systems (system: systemResults.${system}.env.checks); formatter = lib.genAttrs systems (system: systemResults.${system}.env.formatter); packages = lib.genAttrs systems (system: systemResults.${system}.packages); diff --git a/packages/repo-lib/shell-hook.sh b/packages/repo-lib/shell-hook.sh index 696f3be..ce892f0 100644 --- a/packages/repo-lib/shell-hook.sh +++ b/packages/repo-lib/shell-hook.sh @@ -1,4 +1,4 @@ -${pre-commit-check.shellHook} +@HOOKS_SHELL_HOOK@ if [ -t 1 ]; then command -v tput >/dev/null 2>&1 && tput clear || printf '\033c' diff --git a/skills/repo-lib-consumer/references/api.md b/skills/repo-lib-consumer/references/api.md index 110a375..657ea57 100644 --- a/skills/repo-lib-consumer/references/api.md +++ b/skills/repo-lib-consumer/references/api.md @@ -52,7 +52,8 @@ repo-lib.lib.mkRepo { Generated outputs: - `devShells.${system}.default` -- `checks.${system}.pre-commit-check` +- `checks.${system}.hook-check` +- `checks.${system}.lefthook-check` - `formatter.${system}` - `packages.${system}.release` when `config.release != null` - merged `packages` and `apps` from `perSystem` @@ -112,7 +113,8 @@ Defaults: Rules: - Only `pre-commit` and `pre-push` are supported. -- The command is wrapped as a script and connected into `git-hooks.nix`. +- The command is wrapped as a script and connected into `lefthook.nix`. +- `pre-commit` and `pre-push` commands are configured to run in parallel. ## Tools diff --git a/template/.gitignore b/template/.gitignore index 2f88ca4..8e18ed9 100644 --- a/template/.gitignore +++ b/template/.gitignore @@ -1,8 +1,9 @@ .direnv/ .pre-commit-config.yaml +lefthook.yml bazel-* build/ dist/ -node_modules/ \ No newline at end of file +node_modules/ diff --git a/template/flake.nix b/template/flake.nix index 38e6f83..2a52236 100644 --- a/template/flake.nix +++ b/template/flake.nix @@ -40,6 +40,7 @@ }; formatting = { + # nixfmt is enabled by default and wired into lefthook. programs = { # shfmt.enable = true; # gofmt.enable = true; @@ -50,14 +51,31 @@ }; }; - checks.tests = { - command = "echo 'No tests defined yet.'"; - stage = "pre-push"; - passFilenames = false; + # These checks become lefthook commands in the generated `lefthook.yml`. + # repo-lib runs `pre-commit` and `pre-push` hook commands in parallel. + checks = { + tests = { + command = "echo 'No tests defined yet.'"; + stage = "pre-push"; + passFilenames = false; + }; + + # fmt = { + # command = "nix fmt"; + # stage = "pre-commit"; + # passFilenames = false; + # }; }; + # repo-lib also installs built-in hooks for: + # - treefmt / nixfmt on `pre-commit` + # - gitleaks on `pre-commit` + # - gitlint on `commit-msg` + + # release = null; release = { steps = [ + # Write a generated version file during release. # { # writeFile = { # path = "src/version.ts"; @@ -66,6 +84,8 @@ # ''; # }; # } + + # Replace a version string while preserving surrounding captures. # { # replace = { # path = "README.md"; @@ -73,6 +93,16 @@ # replacement = ''\1$FULL_VERSION\2''; # }; # } + + # Run any extra release step with declared runtime inputs. + # { + # run = { + # runtimeInputs = [ pkgs.git ]; + # script = '' + # git status --short + # ''; + # }; + # } ]; }; }; @@ -113,9 +143,16 @@ ]; # checks.lint = { - # command = "go test ./..."; + # command = "bun test"; # stage = "pre-push"; - # runtimeInputs = [ pkgs.go ]; + # passFilenames = false; + # runtimeInputs = [ pkgs.bun ]; + # }; + + # checks.generated = { + # command = "git diff --exit-code"; + # stage = "pre-commit"; + # passFilenames = false; # }; # packages.my-tool = pkgs.writeShellApplication { diff --git a/tests/release.sh b/tests/release.sh index 1377064..0655c7c 100755 --- a/tests/release.sh +++ b/tests/release.sh @@ -415,7 +415,7 @@ write_legacy_flake() { }; in { - inherit (env) pre-commit-check; + inherit (env) lefthook-check; } ); @@ -1124,7 +1124,7 @@ run_mk_repo_case() { CURRENT_LOG="$workdir/mk-repo.log" run_capture_ok "$case_name: flake show failed" nix flake show --json --no-write-lock-file "$repo_dir" - assert_contains '"pre-commit-check"' "$CURRENT_LOG" "$case_name: missing pre-commit-check" + assert_contains '"lefthook-check"' "$CURRENT_LOG" "$case_name: missing lefthook-check" assert_contains '"release"' "$CURRENT_LOG" "$case_name: missing release package" assert_contains '"example"' "$CURRENT_LOG" "$case_name: missing merged package" @@ -1146,7 +1146,7 @@ run_mk_repo_command_tool_case() { CURRENT_LOG="$workdir/mk-repo-command-tool.log" run_capture_ok "$case_name: flake show failed" nix flake show --json --no-write-lock-file "$repo_dir" - assert_contains '"pre-commit-check"' "$CURRENT_LOG" "$case_name: missing pre-commit-check" + assert_contains '"lefthook-check"' "$CURRENT_LOG" "$case_name: missing lefthook-check" assert_contains '"release"' "$CURRENT_LOG" "$case_name: missing release package" run_capture_ok "$case_name: system nix should be available in shell" bash -c 'cd "$1" && nix develop --no-write-lock-file . -c nix --version' _ "$repo_dir" @@ -1202,7 +1202,7 @@ run_legacy_api_eval_case() { CURRENT_LOG="$workdir/legacy.log" run_capture_ok "$case_name: flake show failed" nix flake show --json "$repo_dir" - assert_contains '"pre-commit-check"' "$CURRENT_LOG" "$case_name: missing pre-commit-check" + assert_contains '"lefthook-check"' "$CURRENT_LOG" "$case_name: missing lefthook-check" assert_contains '"release"' "$CURRENT_LOG" "$case_name: missing release package" rm -rf "$workdir" @@ -1220,7 +1220,7 @@ run_template_eval_case() { CURRENT_LOG="$workdir/template.log" run_capture_ok "$case_name: flake show failed" nix flake show --json "$repo_dir" - assert_contains '"pre-commit-check"' "$CURRENT_LOG" "$case_name: missing pre-commit-check" + assert_contains '"lefthook-check"' "$CURRENT_LOG" "$case_name: missing lefthook-check" assert_contains '"release"' "$CURRENT_LOG" "$case_name: missing release package" rm -rf "$workdir"