├── CRUSH.md ├── src ├── hk-extras.usage.kdl ├── ui │ ├── mod.rs │ └── style.rs ├── cli │ ├── fix.rs │ ├── check.rs │ ├── version.rs │ ├── cache │ │ ├── clear.rs │ │ └── mod.rs │ ├── run │ │ ├── pre_commit.rs │ │ ├── commit_msg.rs │ │ ├── prepare_commit_msg.rs │ │ └── mod.rs │ ├── builtins.rs │ ├── validate.rs │ ├── usage.rs │ ├── completion.rs │ ├── uninstall.rs │ └── util │ │ └── no_commit_to_branch.rs ├── error.rs ├── step_locks.rs ├── hash.rs ├── version.rs ├── step_depends.rs ├── tests │ └── config_unification.rs └── step_test.rs ├── .cargo └── config.toml ├── docs ├── index.md ├── public │ ├── logo.png │ ├── favicon.ico │ ├── hk-demo.gif │ ├── benchmark.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── apple-touch-icon.png │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── about.txt │ ├── site.webmanifest │ ├── javascript-project.pkl │ └── python-project.pkl ├── .gitignore ├── cli │ ├── validate.md │ ├── version.md │ ├── cache │ │ └── clear.md │ ├── builtins.md │ ├── uninstall.md │ ├── completion.md │ ├── util │ │ ├── check-symlinks.md │ │ ├── detect-private-key.md │ │ ├── python-check-ast.md │ │ ├── fix-smart-quotes.md │ │ ├── python-debug-statements.md │ │ ├── check-byte-order-marker.md │ │ ├── fix-byte-order-marker.md │ │ ├── check-case-conflict.md │ │ ├── check-executables-have-shebangs.md │ │ ├── no-commit-to-branch.md │ │ ├── mixed-line-ending.md │ │ ├── end-of-file-fixer.md │ │ ├── check-merge-conflict.md │ │ ├── trailing-whitespace.md │ │ ├── check-added-large-files.md │ │ └── check-conventional-commit.md │ ├── migrate.md │ ├── config │ │ ├── sources.md │ │ ├── explain.md │ │ ├── dump.md │ │ └── get.md │ ├── test.md │ ├── init.md │ ├── install.md │ ├── migrate │ │ └── pre-commit.md │ ├── config.md │ ├── util.md │ ├── fix.md │ ├── check.md │ ├── run │ │ ├── pre-commit.md │ │ ├── pre-push.md │ │ ├── commit-msg.md │ │ └── prepare-commit-msg.md │ └── run.md ├── eslint.config.mjs ├── reference │ └── examples │ │ ├── index.md │ │ └── javascript-project.md ├── package.json ├── .vitepress │ └── theme │ │ └── index.ts └── about.md ├── .prettierignore ├── pkl ├── PklProject.deps.json ├── builtins │ ├── go_sec.pkl │ ├── go_vet.pkl │ ├── reek.pkl │ ├── revive.pkl │ ├── pylint.pkl │ ├── sorbet.pkl │ ├── err_check.pkl │ ├── luacheck.pkl │ ├── erb.pkl │ ├── fasterer.pkl │ ├── flake8.pkl │ ├── staticcheck.pkl │ ├── xmllint.pkl │ ├── go_vuln_check.pkl │ ├── astro.pkl │ ├── brakeman.pkl │ ├── mypy.pkl │ ├── tf_lint.pkl │ ├── jq.pkl │ ├── php_cs.pkl │ ├── deno_check.pkl │ ├── nix_fmt.pkl │ ├── go_fumpt.pkl │ ├── gomod_tidy.pkl │ ├── rubocop.pkl │ ├── tsserver.pkl │ ├── go_lines.pkl │ ├── isort.pkl │ ├── stylua.pkl │ ├── go_imports.pkl │ ├── mix_test.pkl │ ├── alejandra.pkl │ ├── standard_rb.pkl │ ├── mix_compile.pkl │ ├── nixpkgs_format.pkl │ ├── sql_fluff.pkl │ ├── bundle_audit.pkl │ ├── check_conventional_commit.pkl │ ├── actionlint.pkl │ ├── cpp_lint.pkl │ ├── cargo_check.pkl │ ├── golangci_lint.pkl │ ├── markdown_lint.pkl │ ├── deno.pkl │ ├── tofu.pkl │ ├── ox_lint.pkl │ ├── sort_package_json.pkl │ ├── xo.pkl │ ├── mix_fmt.pkl │ ├── standard_js.pkl │ ├── terraform.pkl │ ├── biome.pkl │ ├── eslint.pkl │ ├── lychee.pkl │ ├── cargo_fmt.pkl │ ├── go_fmt.pkl │ ├── clang_format.pkl │ ├── dprint.pkl │ ├── rustfmt.pkl │ ├── typos.pkl │ ├── vacuum.pkl │ ├── pkl.pkl │ ├── yamllint.pkl │ ├── hadolint.pkl │ ├── shfmt.pkl │ ├── cargo_clippy.pkl │ ├── check_merge_conflict.pkl │ ├── taplo.pkl │ ├── shellcheck.pkl │ ├── fix_byte_order_marker.pkl │ ├── fix_smart_quotes.pkl │ ├── no_commit_to_branch.pkl │ ├── newlines.pkl │ ├── tombi.pkl │ ├── check_added_large_files.pkl │ ├── tsc.pkl │ ├── check_case_conflict.pkl │ ├── trailing_whitespace.pkl │ ├── black.pkl │ ├── python_check_ast.pkl │ ├── check_byte_order_marker.pkl │ ├── yq.pkl │ ├── swiftlint.pkl │ ├── taplo_format.pkl │ ├── yamlfmt.pkl │ ├── mixed_line_ending.pkl │ ├── check_symlinks.pkl │ ├── ktlint.pkl │ ├── tombi_format.pkl │ ├── detect_private_key.pkl │ ├── check_executables_have_shebangs.pkl │ ├── python_debug_statements.pkl │ ├── mise.pkl │ ├── ruff_format.pkl │ ├── ruff.pkl │ ├── pkl_format.pkl │ ├── prettier.pkl │ └── stylelint.pkl ├── Types.pkl ├── PklProject └── UserConfig.pkl ├── mise-tasks ├── package-pkl.sh └── update-version.sh ├── hk.code-workspace ├── test ├── builtin_tool_stubs │ ├── tombi │ ├── ktlint │ ├── black │ ├── yq │ ├── ruff │ ├── stylelint │ ├── swiftlint │ ├── yamllint │ ├── yamlfmt │ ├── hadolint │ └── shellcheck ├── data │ ├── unpretty.js │ ├── eslint.config.mjs │ └── package.json ├── init_creates_hk_pkl.bats ├── version.bats ├── install_creates_git_hooks.bats ├── validate.bats ├── arg_escape.bats ├── builtins_tests.bats ├── prepare_commit_msg.bats ├── hk_test_failure.bats ├── skip_step_flag.bats ├── builtin_json.bats ├── hk_pkl_http_proxy.bats ├── builtin_json_format.bats ├── hk_test_env.bats ├── run_pre_commit_all.bats ├── commit_msg.bats ├── condition.bats ├── check_fix_suggestion_fix_mode.bats ├── git_runs_pre_commit_on_staged_files.bats ├── skip_hook.bats ├── uninstall.bats ├── hk_test_project_paths.bats ├── skip_steps.bats ├── hk_test_files_default.bats ├── builtins.bats ├── skip_missing_run_cmd.bats ├── config_pkl_imports.bats ├── pre_push.bats ├── depends_condition_false.bats ├── dir.bats ├── localconfig.bats ├── untracked_all.bats ├── hook_fix_default.bats ├── check_first_waits.bats ├── hk_test_sandboxing.bats ├── stage_generated_files.bats ├── fail_fast_config.bats ├── stash_default.bats ├── test_helper │ ├── cache_setup.bash │ └── common_setup.bash ├── workspace_indicator.bats ├── git_status_ad_deleted.bats ├── depends.bats ├── fix_from_ref_to_ref.bats ├── skip_reason_precedence.bats ├── stash_prefers_unstaged_over_fixes.bats ├── pre_commit_does_not_stash_staged_only_files.bats ├── newline_stripping_bug.bats └── skip_output.bats ├── .github ├── renovate.json └── workflows │ ├── semantic-pr-lint.yml │ └── release-plz.yml ├── .gitignore ├── .vscode └── settings.json ├── bin └── hk ├── eslint.config.mjs ├── README.md ├── benchmark ├── lefthook.yml ├── hk.pkl └── .pre-commit-config.yaml ├── tapes └── hk-demo.tape ├── .taplo.toml ├── .gitmodules ├── package.json ├── CONTRIBUTING.md ├── flake.nix ├── default.nix ├── LICENSE ├── settings-schema.json ├── scripts ├── reflect.pkl └── generate-examples.sh └── flake.lock /CRUSH.md: -------------------------------------------------------------------------------- 1 | ./CLAUDE.md -------------------------------------------------------------------------------- /src/hk-extras.usage.kdl: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/ui/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod style; 2 | -------------------------------------------------------------------------------- /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [patch.crates-io] 2 | clx = { path = "clx" } 3 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: page 3 | --- 4 | 5 | 6 | -------------------------------------------------------------------------------- /docs/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdx/hk/HEAD/docs/public/logo.png -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | docs/.vitepress 2 | test/bats 3 | test/data 4 | test/test_helper 5 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | .vitepress/.temp 2 | .vitepress/cache 3 | .vitepress/dist 4 | gen 5 | -------------------------------------------------------------------------------- /docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdx/hk/HEAD/docs/public/favicon.ico -------------------------------------------------------------------------------- /docs/public/hk-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdx/hk/HEAD/docs/public/hk-demo.gif -------------------------------------------------------------------------------- /docs/public/benchmark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdx/hk/HEAD/docs/public/benchmark.png -------------------------------------------------------------------------------- /pkl/PklProject.deps.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": 1, 3 | "resolvedDependencies": {} 4 | } 5 | -------------------------------------------------------------------------------- /docs/public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdx/hk/HEAD/docs/public/favicon-16x16.png -------------------------------------------------------------------------------- /docs/public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdx/hk/HEAD/docs/public/favicon-32x32.png -------------------------------------------------------------------------------- /docs/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdx/hk/HEAD/docs/public/apple-touch-icon.png -------------------------------------------------------------------------------- /mise-tasks/package-pkl.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euxo pipefail 3 | 4 | pkl project package pkl 5 | -------------------------------------------------------------------------------- /hk.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": {} 8 | } 9 | -------------------------------------------------------------------------------- /test/builtin_tool_stubs/tombi: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S mise tool-stub 2 | 3 | version = "0.7.4" 4 | tool = "tombi" 5 | -------------------------------------------------------------------------------- /docs/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdx/hk/HEAD/docs/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /docs/public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdx/hk/HEAD/docs/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /test/builtin_tool_stubs/ktlint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S mise tool-stub 2 | 3 | version = "1.7.1" 4 | tool = "ktlint" 5 | -------------------------------------------------------------------------------- /test/builtin_tool_stubs/black: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S mise tool-stub 2 | 3 | version = "25.11.0" 4 | tool = "pipx:black" 5 | -------------------------------------------------------------------------------- /test/builtin_tool_stubs/yq: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S mise tool-stub 2 | 3 | version = "4.49.2" 4 | tool = "aqua:mikefarah/yq" 5 | -------------------------------------------------------------------------------- /test/builtin_tool_stubs/ruff: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S mise tool-stub 2 | 3 | version = "0.13.3" 4 | tool = "aqua:astral-sh/ruff" 5 | -------------------------------------------------------------------------------- /test/builtin_tool_stubs/stylelint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S mise tool-stub 2 | 3 | version = "16.23.1" 4 | tool = "npm:stylelint" 5 | -------------------------------------------------------------------------------- /test/builtin_tool_stubs/swiftlint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S mise tool-stub 2 | 3 | version = "0.59.1" 4 | tool = "asdf:swiftlint" 5 | -------------------------------------------------------------------------------- /test/builtin_tool_stubs/yamllint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S mise tool-stub 2 | 3 | version = "1.37.1" 4 | tool = "pipx:yamllint" 5 | -------------------------------------------------------------------------------- /test/builtin_tool_stubs/yamlfmt: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S mise tool-stub 2 | 3 | version = "0.20.0" 4 | tool = "aqua:google/yamlfmt" 5 | -------------------------------------------------------------------------------- /test/builtin_tool_stubs/hadolint: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S mise tool-stub 2 | 3 | version = "2.12.1-beta" 4 | tool = "aqua:hadolint/hadolint" 5 | -------------------------------------------------------------------------------- /test/builtin_tool_stubs/shellcheck: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S mise tool-stub 2 | 3 | version = "0.11.0" 4 | tool = "ubi:koalaman/shellcheck" 5 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["local>jdx/renovate-config"] 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /tmp 3 | node_modules 4 | .aider* 5 | .pkl-lsp 6 | /.pre-commit-config.yaml 7 | /lefthook.yml 8 | .hkrc.pkl 9 | *.log 10 | !/build 11 | -------------------------------------------------------------------------------- /docs/cli/validate.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk validate` 4 | 5 | - **Usage**: `hk validate` 6 | 7 | Validate the config file 8 | -------------------------------------------------------------------------------- /docs/cli/version.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk version` 4 | 5 | - **Usage**: `hk version` 6 | 7 | Print the version of hk 8 | -------------------------------------------------------------------------------- /docs/cli/cache/clear.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk cache clear` 4 | 5 | - **Usage**: `hk cache clear` 6 | 7 | Clear the cache directory 8 | -------------------------------------------------------------------------------- /docs/cli/builtins.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk builtins` 4 | 5 | - **Usage**: `hk builtins` 6 | 7 | Lists all available builtin linters 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "node_modules": true, 4 | "target": true, 5 | "test/bats": true, 6 | "test/test_helper/bats-*": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /bin/hk: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | script_dir=$(dirname "$0") 5 | 6 | exec cargo run --all-features --manifest-path "$script_dir/../Cargo.toml" --bin hk -- "$@" 7 | -------------------------------------------------------------------------------- /docs/cli/uninstall.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk uninstall` 4 | 5 | - **Usage**: `hk uninstall` 6 | 7 | Removes hk hooks from the current git repository 8 | -------------------------------------------------------------------------------- /src/cli/fix.rs: -------------------------------------------------------------------------------- 1 | use crate::hook_options::HookOptions; 2 | 3 | /// Fixes code 4 | #[derive(clap::Args)] 5 | #[clap(visible_alias = "f")] 6 | pub struct Fix { 7 | #[clap(flatten)] 8 | pub(crate) hook: HookOptions, 9 | } 10 | -------------------------------------------------------------------------------- /src/cli/check.rs: -------------------------------------------------------------------------------- 1 | use crate::hook_options::HookOptions; 2 | 3 | /// Checks code 4 | #[derive(clap::Args)] 5 | #[clap(visible_alias = "c")] 6 | pub struct Check { 7 | #[clap(flatten)] 8 | pub(crate) hook: HookOptions, 9 | } 10 | -------------------------------------------------------------------------------- /docs/public/about.txt: -------------------------------------------------------------------------------- 1 | This favicon was generated using the following font: 2 | 3 | - Font Title: Lato 4 | - Font Author: undefined 5 | - Font Source: https://fonts.gstatic.com/s/lato/v24/S6uyw4BMUTPHvxk6XweuBCY.ttf 6 | - Font License: undefined) 7 | -------------------------------------------------------------------------------- /pkl/builtins/go_sec.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Go" 6 | description = "Security scanner" 7 | } 8 | go_sec = new Config.Step { 9 | glob = "**/*.go" 10 | check = "gosec {{ files }}" 11 | } 12 | -------------------------------------------------------------------------------- /pkl/builtins/go_vet.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Go" 6 | description = "Go code vetting" 7 | } 8 | go_vet = new Config.Step { 9 | glob = "**/*.go" 10 | check = "go vet {{ files }}" 11 | } 12 | -------------------------------------------------------------------------------- /pkl/builtins/reek.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Ruby" 6 | description = "Code smell detector" 7 | } 8 | reek = new Config.Step { 9 | glob = "**/*.rb" 10 | check = "reek {{ files }}" 11 | } 12 | -------------------------------------------------------------------------------- /pkl/builtins/revive.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Go" 6 | description = "Fast Go linter" 7 | } 8 | revive = new Config.Step { 9 | glob = "**/*.go" 10 | check = "revive {{ files }}" 11 | } 12 | -------------------------------------------------------------------------------- /pkl/builtins/pylint.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Python" 6 | description = "Python code analysis" 7 | } 8 | pylint = new Config.Step { 9 | glob = "**/*.py" 10 | check = "pylint {{ files }}" 11 | } 12 | -------------------------------------------------------------------------------- /pkl/builtins/sorbet.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Ruby" 6 | description = "Type checker for Ruby" 7 | } 8 | sorbet = new Config.Step { 9 | glob = "**/*.rb" 10 | check = "srb tc {{ files }}" 11 | } 12 | -------------------------------------------------------------------------------- /pkl/builtins/err_check.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Go" 6 | description = "Error handling checker" 7 | } 8 | err_check = new Config.Step { 9 | glob = "**/*.go" 10 | check = "errcheck {{ files }}" 11 | } 12 | -------------------------------------------------------------------------------- /pkl/builtins/luacheck.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Other Languages" 6 | description = "Lua linter" 7 | } 8 | luacheck = new Config.Step { 9 | glob = "**/*.lua" 10 | check = "luacheck {{ files }}" 11 | } 12 | -------------------------------------------------------------------------------- /docs/cli/completion.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk completion` 4 | 5 | - **Usage**: `hk completion ` 6 | 7 | Generates shell completion scripts 8 | 9 | ## Arguments 10 | 11 | ### `` 12 | 13 | The shell to generate completion for 14 | -------------------------------------------------------------------------------- /docs/cli/util/check-symlinks.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk util check-symlinks` 4 | 5 | - **Usage**: `hk util check-symlinks …` 6 | 7 | Check for broken symlinks 8 | 9 | ## Arguments 10 | 11 | ### `…` 12 | 13 | Files to check 14 | -------------------------------------------------------------------------------- /docs/public/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} 2 | -------------------------------------------------------------------------------- /pkl/builtins/erb.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Ruby" 6 | description = "ERB template linter" 7 | } 8 | erb = new Config.Step { 9 | glob = "**/*.erb" 10 | check = "erb -P -x -T - {{ files }} | ruby -c" 11 | } 12 | -------------------------------------------------------------------------------- /pkl/builtins/fasterer.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Ruby" 6 | description = "Performance suggestions" 7 | } 8 | fasterer = new Config.Step { 9 | glob = "**/*.rb" 10 | check = "fasterer {{ files }}" 11 | } 12 | -------------------------------------------------------------------------------- /pkl/builtins/flake8.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Python" 6 | description = "Python style guide enforcement" 7 | } 8 | flake8 = new Config.Step { 9 | glob = "**/*.py" 10 | check = "flake8 {{ files }}" 11 | } 12 | -------------------------------------------------------------------------------- /pkl/builtins/staticcheck.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Go" 6 | description = "Go static analysis" 7 | } 8 | staticcheck = new Config.Step { 9 | glob = "**/*.go" 10 | check = "staticcheck {{ files }}" 11 | } 12 | -------------------------------------------------------------------------------- /pkl/builtins/xmllint.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Data Formats" 6 | description = "XML validator" 7 | } 8 | xmllint = new Config.Step { 9 | glob = "**/*.xml" 10 | check = "xmllint --noout {{ files }}" 11 | } 12 | -------------------------------------------------------------------------------- /docs/cli/migrate.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk migrate` 4 | 5 | - **Usage**: `hk migrate ` 6 | 7 | Migrate from other hook managers to hk 8 | 9 | ## Subcommands 10 | 11 | - [`hk migrate pre-commit [FLAGS]`](/cli/migrate/pre-commit.md) 12 | -------------------------------------------------------------------------------- /pkl/builtins/go_vuln_check.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Go" 6 | description = "Vulnerability scanner" 7 | } 8 | go_vuln_check = new Config.Step { 9 | glob = "**/*.go" 10 | check = "govulncheck {{ files }}" 11 | } 12 | -------------------------------------------------------------------------------- /pkl/builtins/astro.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Other Languages" 6 | description = "Astro component checker" 7 | } 8 | astro = new Config.Step { 9 | glob = "**/*.astro" 10 | check = "astro check {{ files }}" 11 | } 12 | -------------------------------------------------------------------------------- /docs/cli/util/detect-private-key.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk util detect-private-key` 4 | 5 | - **Usage**: `hk util detect-private-key …` 6 | 7 | Detect private keys in files 8 | 9 | ## Arguments 10 | 11 | ### `…` 12 | 13 | Files to check 14 | -------------------------------------------------------------------------------- /docs/cli/util/python-check-ast.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk util python-check-ast` 4 | 5 | - **Usage**: `hk util python-check-ast …` 6 | 7 | Check Python files for valid syntax 8 | 9 | ## Arguments 10 | 11 | ### `…` 12 | 13 | Files to check 14 | -------------------------------------------------------------------------------- /pkl/builtins/brakeman.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Ruby" 6 | description = "Security scanner for Rails" 7 | } 8 | brakeman = new Config.Step { 9 | glob = List("**/*.rb") 10 | check = "brakeman -q -w2 {{ files }}" 11 | } 12 | -------------------------------------------------------------------------------- /pkl/builtins/mypy.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Python" 6 | description = "Static type checker for Python" 7 | } 8 | mypy = new Config.Step { 9 | glob = List("**/*.py", "**/*.pyi") 10 | check = "mypy {{ files }}" 11 | } 12 | -------------------------------------------------------------------------------- /test/data/unpretty.js: -------------------------------------------------------------------------------- 1 | class Base { 2 | constructor() {} 3 | 4 | } 5 | 6 | class MyClass extends Base { 7 | constructor() { 8 | super(); 9 | } 10 | 11 | method() { 12 | return 1; 13 | } 14 | } 15 | 16 | let mc = new MyClass() 17 | console . log ( `Hello World! ${mc.method()}` ); 18 | -------------------------------------------------------------------------------- /docs/cli/util/fix-smart-quotes.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk util fix-smart-quotes` 4 | 5 | - **Usage**: `hk util fix-smart-quotes …` 6 | 7 | Replace UTF-8 smart quotes 8 | 9 | ## Arguments 10 | 11 | ### `…` 12 | 13 | Files to replace smart quotes in 14 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | //pub use std::error::*; 2 | 3 | #[derive(Debug, thiserror::Error)] 4 | pub enum Error { 5 | #[error("check list failed: {source}")] 6 | CheckListFailed { 7 | #[source] 8 | source: eyre::Error, 9 | stdout: String, 10 | stderr: String, 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /test/init_creates_hk_pkl.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | 8 | teardown() { 9 | _common_teardown 10 | } 11 | 12 | @test "hk init creates hk.pkl" { 13 | hk init 14 | assert_file_contains hk.pkl "linters =" 15 | } 16 | -------------------------------------------------------------------------------- /docs/cli/util/python-debug-statements.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk util python-debug-statements` 4 | 5 | - **Usage**: `hk util python-debug-statements …` 6 | 7 | Detect Python debug statements 8 | 9 | ## Arguments 10 | 11 | ### `…` 12 | 13 | Files to check 14 | -------------------------------------------------------------------------------- /src/cli/version.rs: -------------------------------------------------------------------------------- 1 | use crate::Result; 2 | use crate::version; 3 | 4 | /// Print the version of hk 5 | #[derive(Debug, clap::Args)] 6 | pub struct Version {} 7 | 8 | impl Version { 9 | pub async fn run(&self) -> Result<()> { 10 | println!("{}", version::version()); 11 | Ok(()) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /docs/cli/util/check-byte-order-marker.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk util check-byte-order-marker` 4 | 5 | - **Usage**: `hk util check-byte-order-marker …` 6 | 7 | Check for UTF-8 byte order marker (BOM) 8 | 9 | ## Arguments 10 | 11 | ### `…` 12 | 13 | Files to check 14 | -------------------------------------------------------------------------------- /docs/cli/util/fix-byte-order-marker.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk util fix-byte-order-marker` 4 | 5 | - **Usage**: `hk util fix-byte-order-marker …` 6 | 7 | Remove UTF-8 byte order marker (BOM) 8 | 9 | ## Arguments 10 | 11 | ### `…` 12 | 13 | Files to remove BOM from 14 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | 3 | 4 | /** @type {import('eslint').Linter.Config[]} */ 5 | export default [ 6 | {ignores: [ 7 | "docs/.vitepress/cache/**/*", 8 | "test/{bats,test_helper}/**/*", 9 | "target/**/*", 10 | ]}, 11 | {languageOptions: { globals: globals.node }}, 12 | ]; 13 | -------------------------------------------------------------------------------- /pkl/builtins/tf_lint.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Infrastructure" 6 | description = "Terraform linter" 7 | } 8 | tf_lint = new Config.Step { 9 | glob = "**/*.tf" 10 | stage = "" 11 | check = "tflint" 12 | fix = "tflint --fix" 13 | } 14 | -------------------------------------------------------------------------------- /test/version.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | 8 | teardown() { 9 | _common_teardown 10 | } 11 | 12 | @test "hk --version prints version" { 13 | run hk --version 14 | assert_output --regexp "^hk\ [0-9]+\.[0-9]+\.[0-9]+$" 15 | } 16 | -------------------------------------------------------------------------------- /docs/cli/config/sources.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk config sources` 4 | 5 | - **Usage**: `hk config sources` 6 | 7 | Show the configuration source precedence order 8 | 9 | Lists all configuration sources in order of precedence to help understand where configuration values come from. 10 | -------------------------------------------------------------------------------- /pkl/builtins/jq.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Data Formats" 6 | description = "JSON processor" 7 | } 8 | jq = new Config.Step { 9 | glob = "**/*.json" 10 | stage = "" 11 | check = "jq . {{ files }}" 12 | fix = "jq -S . {{ files }}" 13 | } 14 | -------------------------------------------------------------------------------- /test/data/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import js from "@eslint/js"; 3 | 4 | 5 | /** @type {import('eslint').Linter.Config[]} */ 6 | export default [ 7 | {languageOptions: { globals: globals.node }}, 8 | js.configs.recommended, 9 | {rules:{ 10 | semi: ["error", "always"], 11 | }} 12 | ]; 13 | -------------------------------------------------------------------------------- /pkl/builtins/php_cs.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "PHP" 6 | description = "PHP coding standards" 7 | } 8 | php_cs = new Config.Step { 9 | glob = "**/*.php" 10 | stage = "" 11 | check = "phpcs {{ files }}" 12 | fix = "phpcbf {{ files }}" 13 | } 14 | -------------------------------------------------------------------------------- /docs/cli/util/check-case-conflict.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk util check-case-conflict` 4 | 5 | - **Usage**: `hk util check-case-conflict …` 6 | 7 | Check for case-insensitive filename conflicts 8 | 9 | ## Arguments 10 | 11 | ### `…` 12 | 13 | Files to check for case conflicts 14 | -------------------------------------------------------------------------------- /pkl/builtins/deno_check.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "JavaScript/TypeScript" 6 | description = "Deno type checker" 7 | } 8 | deno_check = new Config.Step { 9 | glob = List("**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx") 10 | check = "deno check {{ files }}" 11 | } 12 | -------------------------------------------------------------------------------- /pkl/builtins/nix_fmt.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Nix" 6 | description = "Nix formatter" 7 | } 8 | nix_fmt = new Config.Step { 9 | glob = "**/*.nix" 10 | stage = "" 11 | check = "nixfmt --check {{ files }}" 12 | fix = "nixfmt {{ files }}" 13 | } 14 | -------------------------------------------------------------------------------- /pkl/builtins/go_fumpt.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Go" 6 | description = "Strict Go formatter" 7 | } 8 | go_fumpt = new Config.Step { 9 | glob = "**/*.go" 10 | stage = "" 11 | check = "gofumpt -l {{ files }}" 12 | fix = "gofumpt -w {{ files }}" 13 | } 14 | -------------------------------------------------------------------------------- /pkl/builtins/gomod_tidy.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Go" 6 | description = "Go module maintenance" 7 | } 8 | gomod_tidy = new Config.Step { 9 | glob = "**/go.mod" 10 | stage = "" 11 | check_diff = "go mod tidy -diff" 12 | fix = "go mod tidy" 13 | } 14 | -------------------------------------------------------------------------------- /pkl/builtins/rubocop.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Ruby" 6 | description = "Ruby style guide" 7 | } 8 | rubocop = new Config.Step { 9 | glob = "**/*.rb" 10 | stage = "" 11 | check = "rubocop {{ files }}" 12 | fix = "rubocop --fix {{ files }}" 13 | } 14 | -------------------------------------------------------------------------------- /pkl/builtins/tsserver.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "JavaScript/TypeScript" 6 | description = "TypeScript language server diagnostics" 7 | } 8 | tsserver = new Config.Step { 9 | glob = List("**/*.ts", "**/*.tsx") 10 | check = "tsc-files --noEmit {{ files }}" 11 | } 12 | -------------------------------------------------------------------------------- /docs/cli/util/check-executables-have-shebangs.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk util check-executables-have-shebangs` 4 | 5 | - **Usage**: `hk util check-executables-have-shebangs …` 6 | 7 | Check that executable files have shebangs 8 | 9 | ## Arguments 10 | 11 | ### `…` 12 | 13 | Files to check 14 | -------------------------------------------------------------------------------- /pkl/builtins/go_lines.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Go" 6 | description = "Long line fixer" 7 | } 8 | go_lines = new Config.Step { 9 | glob = "**/*.go" 10 | stage = "" 11 | check = "golines --dry-run {{ files }}" 12 | fix = "golines -w {{ files }}" 13 | } 14 | -------------------------------------------------------------------------------- /pkl/builtins/isort.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Python" 6 | description = "Python import sorter" 7 | } 8 | isort = new Config.Step { 9 | glob = "**/*.py" 10 | stage = "" 11 | check = "isort --check-only {{ files }}" 12 | fix = "isort {{ files }}" 13 | } 14 | -------------------------------------------------------------------------------- /pkl/builtins/stylua.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Other Languages" 6 | description = "Lua formatter" 7 | } 8 | stylua = new Config.Step { 9 | glob = "**/*.lua" 10 | stage = "" 11 | check = "stylua --check {{ files }}" 12 | fix = "stylua {{ files }}" 13 | } 14 | -------------------------------------------------------------------------------- /pkl/builtins/go_imports.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Go" 6 | description = "Go import management" 7 | } 8 | go_imports = new Config.Step { 9 | glob = "**/*.go" 10 | stage = "" 11 | check = "goimports -l {{ files }}" 12 | fix = "goimports -w {{ files }}" 13 | } 14 | -------------------------------------------------------------------------------- /pkl/builtins/mix_test.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Elixir" 6 | description = "Test Elixir with Mix" 7 | } 8 | mix_test = new Config.Step { 9 | glob = List("**/*.ex", "**/*.exs") 10 | exclude = List("deps/**/*") 11 | check = "mix test --warnings-as-errors {{files}}" 12 | } 13 | -------------------------------------------------------------------------------- /docs/cli/util/no-commit-to-branch.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk util no-commit-to-branch` 4 | 5 | - **Usage**: `hk util no-commit-to-branch [--branch… ]` 6 | 7 | Prevent commits to specific branches 8 | 9 | ## Flags 10 | 11 | ### `--branch… ` 12 | 13 | Branch names to protect (default: main, master) 14 | -------------------------------------------------------------------------------- /pkl/builtins/alejandra.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Nix" 6 | description = "Alternative Nix formatter" 7 | } 8 | alejandra = new Config.Step { 9 | glob = "**/*.nix" 10 | stage = "" 11 | check = "alejandra --check {{ files }}" 12 | fix = "alejandra {{ files }}" 13 | } 14 | -------------------------------------------------------------------------------- /pkl/builtins/standard_rb.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Ruby" 6 | description = "Ruby Standard Style" 7 | } 8 | standard_rb = new Config.Step { 9 | glob = "**/*.rb" 10 | stage = "" 11 | check = "standardrb {{ files }}" 12 | fix = "standardrb --fix {{ files }}" 13 | } 14 | -------------------------------------------------------------------------------- /docs/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from "globals"; 2 | import tseslint from "typescript-eslint"; 3 | 4 | 5 | /** @type {import('eslint').Linter.Config[]} */ 6 | export default [ 7 | {files: ["**/*.{js,mjs,cjs,ts}"]}, 8 | {ignores: [".vitepress/**/*"]}, 9 | {languageOptions: { globals: globals.browser }}, 10 | ...tseslint.configs.recommended, 11 | ]; 12 | -------------------------------------------------------------------------------- /docs/reference/examples/index.md: -------------------------------------------------------------------------------- 1 | # Configuration Examples 2 | 3 | This directory contains runnable examples extracted from the public Pkl configurations. 4 | 5 | ## Available Examples 6 | 7 | - [custom-linters](./custom-linters.md) 8 | - [javascript-project](./javascript-project.md) 9 | - [monorepo](./monorepo.md) 10 | - [python-project](./python-project.md) 11 | -------------------------------------------------------------------------------- /pkl/builtins/mix_compile.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Elixir" 6 | description = "Compile Elixir with Mix" 7 | } 8 | mix_compile = new Config.Step { 9 | glob = "**/*.ex" 10 | exclude = List("deps/**/*") 11 | check = "mix compile --warnings-as-errors --strict-errors {{files}}" 12 | } 13 | -------------------------------------------------------------------------------- /pkl/builtins/nixpkgs_format.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Nix" 6 | description = "Nixpkgs formatter" 7 | } 8 | nixpkgs_format = new Config.Step { 9 | glob = "**/*.nix" 10 | stage = "" 11 | check = "nixpkgs-fmt --check {{ files }}" 12 | fix = "nixpkgs-fmt {{ files }}" 13 | } 14 | -------------------------------------------------------------------------------- /pkl/builtins/sql_fluff.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Data Formats" 6 | description = "SQL linter and formatter" 7 | } 8 | sql_fluff = new Config.Step { 9 | glob = "**/*.sql" 10 | stage = "" 11 | check = "sqlfluff lint {{ files }}" 12 | fix = "sqlfluff fix {{ files }}" 13 | } 14 | -------------------------------------------------------------------------------- /pkl/builtins/bundle_audit.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Ruby" 6 | description = "Dependency security audit" 7 | } 8 | bundle_audit = new Config.Step { 9 | glob = "**/Gemfile.lock" 10 | stage = "" 11 | check = "bundle-audit check {{ files }}" 12 | fix = "bundle-audit update" 13 | } 14 | -------------------------------------------------------------------------------- /pkl/builtins/check_conventional_commit.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Special Purpose" 6 | description = "Verify commit message matches conventional commits formatting" 7 | } 8 | check_conventional_commit = new Config.Step { 9 | check = "hk util check-conventional-commit {{commit_msg_file}}" 10 | } 11 | -------------------------------------------------------------------------------- /pkl/Types.pkl: -------------------------------------------------------------------------------- 1 | @ModuleInfo { minPklVersion = "0.27.2" } 2 | module hk.Types 3 | import "pkl:base" 4 | 5 | /// Helper function to create regex patterns with clean syntax 6 | @Deprecated { 7 | since = "1.27.1" 8 | message = "Replace `Types.Regex` with `Regex` (pkl built-in)" 9 | replaceWith = "Regex" 10 | } 11 | function Regex(pattern: String): Regex = base.Regex(pattern) 12 | -------------------------------------------------------------------------------- /pkl/builtins/actionlint.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Infrastructure" 6 | description = "GitHub Actions workflow linter" 7 | } 8 | const actionlint = new Config.Step { 9 | glob = List(".github/workflows/*.yml", ".github/workflows/*.yaml") 10 | batch = true 11 | check = "actionlint {{ files }}" 12 | } 13 | -------------------------------------------------------------------------------- /pkl/builtins/cpp_lint.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Other Languages" 6 | description = "C++ style checker" 7 | } 8 | cpp_lint = new Config.Step { 9 | glob = 10 | List("**/*.c", "**/*.h", "**/*.cpp", "**/*.hpp", "**/*.cc", "**/*.hh", "**/*.cxx", "**/*.hxx") 11 | check = "cpplint {{ files }}" 12 | } 13 | -------------------------------------------------------------------------------- /pkl/builtins/cargo_check.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Rust" 6 | description = "Fast Rust type checking" 7 | } 8 | cargo_check = new Config.Step { 9 | glob = "**/*.rs" 10 | check = "cargo check -q" 11 | env { 12 | ["CARGO_TERM_COLOR"] = "{% if color %}always{% else %}never{% endif %}" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /pkl/builtins/golangci_lint.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Go" 6 | description = "Go meta-linter" 7 | } 8 | golangci_lint = new Config.Step { 9 | glob = "**/*.go" 10 | stage = "" 11 | check = "golangci-lint run --fix=false {{ files }}" 12 | fix = "golangci-lint run --fix {{ files }}" 13 | } 14 | -------------------------------------------------------------------------------- /pkl/builtins/markdown_lint.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Markdown" 6 | description = "Markdown linter" 7 | } 8 | markdown_lint = new Config.Step { 9 | glob = List("**/*.md", "**/*.markdown") 10 | stage = "" 11 | check = "markdownlint {{ files }}" 12 | fix = "markdownlint --fix {{ files }}" 13 | } 14 | -------------------------------------------------------------------------------- /docs/cli/test.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk test` 4 | 5 | - **Usage**: `hk test [FLAGS]` 6 | 7 | Run step-defined tests 8 | 9 | ## Flags 10 | 11 | ### `--list` 12 | 13 | List tests without running 14 | 15 | ### `--name… ` 16 | 17 | Filter by test name (repeatable) 18 | 19 | ### `--step… ` 20 | 21 | Filter by step name (repeatable) 22 | -------------------------------------------------------------------------------- /pkl/builtins/deno.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "JavaScript/TypeScript" 6 | description = "Deno formatter" 7 | } 8 | deno = new Config.Step { 9 | glob = List("**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx") 10 | stage = "" 11 | check = "deno fmt --check {{ files }}" 12 | fix = "deno fmt {{ files }}" 13 | } 14 | -------------------------------------------------------------------------------- /src/cli/cache/clear.rs: -------------------------------------------------------------------------------- 1 | use crate::{Result, env}; 2 | 3 | #[derive(Debug, clap::Args)] 4 | pub struct Clear {} 5 | 6 | impl Clear { 7 | pub async fn run(&self) -> Result<()> { 8 | if env::HK_CACHE_DIR.exists() { 9 | xx::file::remove_dir_all(&*env::HK_CACHE_DIR)?; 10 | xx::file::mkdirp(&*env::HK_CACHE_DIR)?; 11 | } 12 | Ok(()) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /pkl/builtins/tofu.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Infrastructure" 6 | description = "OpenTofu formatter" 7 | } 8 | tofu = new Config.Step { 9 | glob = List("**/*.tf", "**/*.tfvars", "**/*.tftest.hcl") 10 | stage = "" 11 | check_list_files = "tofu fmt -check {{ files }}" 12 | fix = "tofu fmt {{ files }}" 13 | } 14 | -------------------------------------------------------------------------------- /src/cli/run/pre_commit.rs: -------------------------------------------------------------------------------- 1 | use crate::{Result, hook_options::HookOptions}; 2 | 3 | /// Sets up git hooks to run hk 4 | #[derive(clap::Args)] 5 | #[clap(visible_alias = "pc")] 6 | pub struct PreCommit { 7 | #[clap(flatten)] 8 | hook: HookOptions, 9 | } 10 | 11 | impl PreCommit { 12 | pub async fn run(self) -> Result<()> { 13 | self.hook.run("pre-commit").await 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/step_locks.rs: -------------------------------------------------------------------------------- 1 | use crate::file_rw_locks::Flocks; 2 | use tokio::sync::OwnedSemaphorePermit; 3 | 4 | #[allow(unused)] 5 | #[derive(Debug)] 6 | pub struct StepLocks { 7 | flocks: Flocks, 8 | semaphore: OwnedSemaphorePermit, 9 | } 10 | 11 | impl StepLocks { 12 | pub fn new(flocks: Flocks, semaphore: OwnedSemaphorePermit) -> Self { 13 | Self { flocks, semaphore } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /test/data/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "data", 3 | "version": "1.0.0", 4 | "main": "unpretty.js", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1" 7 | }, 8 | "author": "", 9 | "license": "ISC", 10 | "description": "", 11 | "devDependencies": { 12 | "@eslint/js": "^9.19.0", 13 | "eslint": "^9.19.0", 14 | "globals": "^15.14.0" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hk 2 | 3 | A git hook manager and project linting tool with an emphasis on performance. Compared to other 4 | git hook managers, hk has tighter integration with linters and is able to make use of read/write 5 | file locks in order to maximize concurrency while also preventing race conditions. 6 | 7 | See docs: https://hk.jdx.dev/ 8 | 9 | ## Demo 10 | 11 | ![hk demo](docs/public/hk-demo.gif) 12 | -------------------------------------------------------------------------------- /pkl/builtins/ox_lint.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "JavaScript/TypeScript" 6 | description = "Oxidation compiler linter" 7 | } 8 | ox_lint = new Config.Step { 9 | glob = List("**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx") 10 | stage = "" 11 | check = "oxlint {{ files }}" 12 | fix = "oxlint --fix {{ files }}" 13 | } 14 | -------------------------------------------------------------------------------- /pkl/builtins/sort_package_json.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Configuration" 6 | description = "Sort package.json keys" 7 | } 8 | sort_package_json = new Config.Step { 9 | glob = "**/package.json" 10 | stage = "" 11 | check = "sort-package-json --check {{ files }}" 12 | fix = "sort-package-json {{ files }}" 13 | } 14 | -------------------------------------------------------------------------------- /pkl/builtins/xo.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "JavaScript/TypeScript" 6 | description = "JavaScript/TypeScript linter with great defaults" 7 | } 8 | xo = new Config.Step { 9 | glob = List("**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx") 10 | stage = "" 11 | check = "xo {{ files }}" 12 | fix = "xo --fix {{ files }}" 13 | } 14 | -------------------------------------------------------------------------------- /src/cli/builtins.rs: -------------------------------------------------------------------------------- 1 | use crate::Result; 2 | 3 | /// Lists all available builtin linters 4 | #[derive(Debug, clap::Args)] 5 | pub struct Builtins; 6 | include!(concat!(env!("OUT_DIR"), "/builtins.rs")); 7 | 8 | impl Builtins { 9 | pub async fn run(&self) -> Result<()> { 10 | for builtin in BUILTINS { 11 | println!("{builtin}"); 12 | } 13 | 14 | Ok(()) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /docs/cli/init.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk init` 4 | 5 | - **Usage**: `hk init [-f --force] [--mise]` 6 | 7 | Generates a new hk.pkl file for a project 8 | 9 | ## Flags 10 | 11 | ### `-f --force` 12 | 13 | Overwrite existing hk.pkl file 14 | 15 | ### `--mise` 16 | 17 | Generate a mise.toml file with hk configured 18 | 19 | Set HK_MISE=1 to make this default behavior. 20 | -------------------------------------------------------------------------------- /docs/cli/util/mixed-line-ending.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk util mixed-line-ending` 4 | 5 | - **Usage**: `hk util mixed-line-ending [-f --fix] …` 6 | 7 | Detect and fix mixed line endings 8 | 9 | ## Arguments 10 | 11 | ### `…` 12 | 13 | Files to check or fix 14 | 15 | ## Flags 16 | 17 | ### `-f --fix` 18 | 19 | Fix mixed line endings by normalizing to LF 20 | -------------------------------------------------------------------------------- /pkl/builtins/mix_fmt.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Elixir" 6 | description = "Format Elixir with Mix" 7 | } 8 | mix_fmt = new Config.Step { 9 | glob = List("**/*.ex", "**/*.exs") 10 | stage = "" 11 | exclude = List("deps/**/*") 12 | check = "mix format --check-formatted {{files}}" 13 | fix = "mix format {{files}}" 14 | } 15 | -------------------------------------------------------------------------------- /pkl/builtins/standard_js.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "JavaScript/TypeScript" 6 | description = "JavaScript Standard Style" 7 | } 8 | standard_js = new Config.Step { 9 | glob = List("**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx") 10 | stage = "" 11 | check = "standard {{ files }}" 12 | fix = "standard --fix {{ files }}" 13 | } 14 | -------------------------------------------------------------------------------- /docs/cli/util/end-of-file-fixer.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk util end-of-file-fixer` 4 | 5 | - **Usage**: `hk util end-of-file-fixer [-f --fix] …` 6 | 7 | Check for and optionally fix missing final newlines 8 | 9 | ## Arguments 10 | 11 | ### `…` 12 | 13 | Files to check/fix 14 | 15 | ## Flags 16 | 17 | ### `-f --fix` 18 | 19 | Fix files by adding final newline 20 | -------------------------------------------------------------------------------- /pkl/builtins/terraform.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Infrastructure" 6 | description = "Terraform formatter" 7 | } 8 | terraform = new Config.Step { 9 | glob = List("**/*.tf", "**/*.tfvars", "**/*.tftest.hcl") 10 | stage = "" 11 | check_list_files = "terraform fmt -check {{ files }}" 12 | fix = "terraform fmt {{ files }}" 13 | } 14 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "scripts": { 4 | "docs:dev": "vitepress dev", 5 | "docs:build": "vitepress build", 6 | "docs:preview": "vitepress preview", 7 | "eslint": "eslint" 8 | }, 9 | "dependencies": { 10 | "eslint": "^9.21.0", 11 | "vitepress": "^1.6.3" 12 | }, 13 | "devDependencies": { 14 | "globals": "^16.0.0", 15 | "typescript-eslint": "^8.24.1" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /pkl/builtins/biome.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "JavaScript/TypeScript" 6 | description = "Fast formatter and linter" 7 | } 8 | biome = new Config.Step { 9 | glob = List("**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx", "**/*.json") 10 | stage = "" 11 | check = "biome check {{ files }}" 12 | fix = "biome check --write {{ files }}" 13 | } 14 | -------------------------------------------------------------------------------- /pkl/builtins/eslint.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "JavaScript/TypeScript" 6 | description = "Pluggable JavaScript linter" 7 | } 8 | eslint = new Config.Step { 9 | glob = List("**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx") 10 | stage = "" 11 | batch = true 12 | check = "eslint {{ files }}" 13 | fix = "eslint --fix {{ files }}" 14 | } 15 | -------------------------------------------------------------------------------- /docs/cli/util/check-merge-conflict.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk util check-merge-conflict` 4 | 5 | - **Usage**: `hk util check-merge-conflict [--assume-in-merge] …` 6 | 7 | Check for merge conflict markers 8 | 9 | ## Arguments 10 | 11 | ### `…` 12 | 13 | Files to check 14 | 15 | ## Flags 16 | 17 | ### `--assume-in-merge` 18 | 19 | Run the check even when not in a merge 20 | -------------------------------------------------------------------------------- /docs/cli/util/trailing-whitespace.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk util trailing-whitespace` 4 | 5 | - **Usage**: `hk util trailing-whitespace [-f --fix] …` 6 | 7 | Check for and optionally fix trailing whitespace 8 | 9 | ## Arguments 10 | 11 | ### `…` 12 | 13 | Files to check/fix 14 | 15 | ## Flags 16 | 17 | ### `-f --fix` 18 | 19 | Fix trailing whitespace by removing it 20 | -------------------------------------------------------------------------------- /docs/cli/config/explain.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk config explain` 4 | 5 | - **Usage**: `hk config explain ` 6 | 7 | Explain where a configuration value comes from 8 | 9 | Shows the resolved value, its source (env/git/cli/default), and the full precedence chain showing all layers that could affect it. 10 | 11 | ## Arguments 12 | 13 | ### `` 14 | 15 | Configuration key to explain 16 | -------------------------------------------------------------------------------- /docs/cli/install.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk install` 4 | 5 | - **Usage**: `hk install [--mise]` 6 | - **Aliases**: `i` 7 | 8 | Sets up git hooks to run hk 9 | 10 | ## Flags 11 | 12 | ### `--mise` 13 | 14 | Use `mise x` to execute hooks. With this, it won't 15 | be necessary to activate mise in order to run hooks 16 | with mise tools. 17 | 18 | Set HK_MISE=1 to make this default behavior. 19 | -------------------------------------------------------------------------------- /pkl/builtins/lychee.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Special Purpose" 6 | description = "Fast, async, stream-based link checker" 7 | } 8 | lychee = new Config.Step { 9 | // https://github.com/lycheeverse/lychee/blob/db0f8a842f594e0a879563caf7d183266c02ca95/.pre-commit-hooks.yaml#L7 10 | types = List("text") 11 | check = "lychee --no-progress {{ files }}" 12 | } 13 | -------------------------------------------------------------------------------- /src/hash.rs: -------------------------------------------------------------------------------- 1 | use std::hash::{Hash, Hasher}; 2 | 3 | use siphasher::sip::SipHasher; 4 | 5 | pub fn hash_to_str(t: &T) -> String { 6 | let mut s = SipHasher::new(); 7 | t.hash(&mut s); 8 | format!("{:x}", s.finish()) 9 | } 10 | 11 | #[cfg(test)] 12 | mod tests { 13 | use super::*; 14 | 15 | #[test] 16 | fn test_hash_to_str() { 17 | assert_eq!(hash_to_str(&"foo"), "e1b19adfb2e348a2"); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /pkl/builtins/cargo_fmt.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Rust" 6 | description = "Rust code formatter" 7 | } 8 | cargo_fmt = new Config.Step { 9 | glob = "**/*.rs" 10 | stage = "" 11 | workspace_indicator = "Cargo.toml" 12 | check = "cargo fmt --check --manifest-path {{workspace_indicator}}" 13 | fix = "cargo fmt --manifest-path {{workspace_indicator}}" 14 | } 15 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.ts: -------------------------------------------------------------------------------- 1 | import { h } from 'vue' 2 | import type { Theme } from 'vitepress' 3 | import DefaultTheme from 'vitepress/theme-without-fonts' 4 | import Layout from './Layout.vue' 5 | import HomePage from './HomePage.vue' 6 | import './style.css' 7 | 8 | export default { 9 | extends: DefaultTheme, 10 | Layout, 11 | enhanceApp({ app, router, siteData }) { 12 | app.component('HomePage', HomePage) 13 | }, 14 | } satisfies Theme 15 | -------------------------------------------------------------------------------- /benchmark/lefthook.yml: -------------------------------------------------------------------------------- 1 | pre-commit: 2 | parallel: true 3 | jobs: 4 | - run: actionlint {staged_files} 5 | glob: ".github/workflows/*.{yml,yaml}" 6 | - run: cargo fmt 7 | glob: "*.rs" 8 | - run: '! rg -e "dbg!" {staged_files}' 9 | glob: "*.rs" 10 | - run: prettier --write {staged_files} 11 | glob: "*.{js,jsx,ts,tsx,css,scss,less,html,json,jsonc,yaml,markdown,markdown.mdx,graphql,handlebars,svelte,astro,htmlangular}" 12 | -------------------------------------------------------------------------------- /pkl/builtins/go_fmt.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Go" 6 | description = "Go formatter" 7 | } 8 | go_fmt = new Config.Step { 9 | glob = "**/*.go" 10 | stage = "" 11 | check_list_files = 12 | """ 13 | FILES=$(gofmt -s -l {{files}}) 14 | if [ -n "$FILES" ]; then 15 | echo "$FILES" 16 | exit 1 17 | fi 18 | """ 19 | fix = "gofmt -s -w {{files}}" 20 | } 21 | -------------------------------------------------------------------------------- /pkl/builtins/clang_format.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Other Languages" 6 | description = "C/C++ formatter" 7 | } 8 | clang_format = new Config.Step { 9 | glob = 10 | List("**/*.c", "**/*.h", "**/*.cpp", "**/*.hpp", "**/*.cc", "**/*.hh", "**/*.cxx", "**/*.hxx") 11 | stage = "" 12 | check = "clang-format --dry-run -Werror {{ files }}" 13 | fix = "clang-format -i {{ files }}" 14 | } 15 | -------------------------------------------------------------------------------- /pkl/builtins/dprint.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Other Languages" 6 | description = "Pluggable code formatter" 7 | } 8 | dprint = new Config.Step { 9 | glob = "**/*" 10 | stage = "" 11 | check = "dprint check --allow-no-files {{ files }}" 12 | check_list_files = "dprint check --allow-no-files --list-different {{ files }}" 13 | fix = "dprint fmt --allow-no-files {{ files }}" 14 | } 15 | -------------------------------------------------------------------------------- /pkl/builtins/rustfmt.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Rust" 6 | description = "Rust code formatter (standalone)" 7 | } 8 | rustfmt = new Config.Step { 9 | glob = "**/*.rs" 10 | stage = "" 11 | check = "rustfmt --check --edition 2024 {{ files }}" 12 | check_list_files = "rustfmt --check --edition 2024 --files-with-diff {{ files }}" 13 | fix = "rustfmt --edition 2024 {{ files }}" 14 | } 15 | -------------------------------------------------------------------------------- /docs/cli/util/check-added-large-files.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk util check-added-large-files` 4 | 5 | - **Usage**: `hk util check-added-large-files [--maxkb ] …` 6 | 7 | Check for large files being added to repository 8 | 9 | ## Arguments 10 | 11 | ### `…` 12 | 13 | Files to check 14 | 15 | ## Flags 16 | 17 | ### `--maxkb ` 18 | 19 | Maximum file size in kilobytes (default: 500) 20 | 21 | **Default:** `500` 22 | -------------------------------------------------------------------------------- /pkl/builtins/typos.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Special Purpose" 6 | description = "Source code spell checker" 7 | } 8 | typos = new Config.Step { 9 | glob = "**/*" 10 | stage = "" 11 | check_diff = 12 | """ 13 | output=$(typos --diff {{files}}) 14 | [ -z "$output" ] && exit 0 15 | printf "%s" "$output" 16 | exit 1 17 | """ 18 | fix = "typos --write-changes {{ files }}" 19 | } 20 | -------------------------------------------------------------------------------- /tapes/hk-demo.tape: -------------------------------------------------------------------------------- 1 | Output docs/public/hk-demo.gif 2 | 3 | Set WindowBar Colorful 4 | Set FontSize 16 5 | Set Theme "Catppuccin Frappe" 6 | Set Padding 10 7 | Set Margin 10 8 | Set Framerate 30 9 | Set Width 900 10 | Set Height 1000 11 | Set PlaybackSpeed 0.8 12 | Set TypingSpeed 100ms 13 | Set CursorBlink false 14 | 15 | Hide 16 | Type "clear" 17 | Enter 18 | Show 19 | 20 | # Run the demo (hk is on PATH via mise env) 21 | Type "hk check --all" 22 | Enter 23 | # Wait@10s /VHS_DONE/ 24 | Sleep 10s 25 | -------------------------------------------------------------------------------- /.github/workflows/semantic-pr-lint.yml: -------------------------------------------------------------------------------- 1 | name: semantic-pr-lint 2 | 3 | on: 4 | pull_request_target: 5 | types: 6 | - opened 7 | - edited 8 | - reopened 9 | 10 | jobs: 11 | main: 12 | name: Validate PR title 13 | runs-on: ubuntu-latest 14 | permissions: 15 | pull-requests: read 16 | steps: 17 | - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | -------------------------------------------------------------------------------- /test/install_creates_git_hooks.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | 8 | teardown() { 9 | _common_teardown 10 | } 11 | 12 | @test "hk install creates git hooks" { 13 | cat < hk.pkl 14 | amends "$PKL_PATH/Config.pkl" 15 | import "$PKL_PATH/Builtins.pkl" 16 | hooks { ["pre-commit"] { steps { ["prettier"] = Builtins.prettier } } } 17 | EOF 18 | hk install 19 | assert_file_exists ".git/hooks/pre-commit" 20 | } 21 | -------------------------------------------------------------------------------- /pkl/builtins/vacuum.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Other Languages" 6 | description = "Fast OpenAPI linter" 7 | } 8 | vacuum = new Config.Step { 9 | glob = 10 | List( 11 | "**/*openapi*.yaml", 12 | "**/*openapi*.yml", 13 | "**/*openapi*.json", 14 | "**/*swagger*.yaml", 15 | "**/*swagger*.yml", 16 | "**/*swagger*.json", 17 | ) 18 | check = "vacuum lint {{files}}" 19 | fix = "vacuum lint --fix {{files}}" 20 | } 21 | -------------------------------------------------------------------------------- /docs/cli/config/dump.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk config dump` 4 | 5 | - **Usage**: `hk config dump [--format ]` 6 | 7 | Print effective runtime settings (JSON format) 8 | 9 | Shows the merged configuration from all sources including CLI flags, environment variables, git config, user config, and project config. 10 | 11 | ## Flags 12 | 13 | ### `--format ` 14 | 15 | Output format (json or toml) 16 | 17 | **Choices:** 18 | 19 | - `json` 20 | - `toml` 21 | 22 | **Default:** `json` 23 | -------------------------------------------------------------------------------- /docs/cli/util/check-conventional-commit.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk util check-conventional-commit` 4 | 5 | - **Usage**: `hk util check-conventional-commit [--allowed-types… ] ` 6 | 7 | Check for conventional commit message 8 | 9 | ## Arguments 10 | 11 | ### `` 12 | 13 | Commit message file to check 14 | 15 | ## Flags 16 | 17 | ### `--allowed-types… ` 18 | 19 | **Default:** `build,chore,ci,docs,feat,fix,perf,refactor,revert,style,test` 20 | -------------------------------------------------------------------------------- /pkl/builtins/pkl.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | import "./test/helpers.pkl" 4 | 5 | @Builtins.meta { 6 | category = "Configuration" 7 | description = "Pkl configuration language" 8 | } 9 | pkl = new Config.Step { 10 | glob = "**/*.pkl" 11 | check = "pkl eval {{files}} >/dev/null" 12 | tests { 13 | local const testMaker = new helpers.TestMaker { filename = "test.pkl" } 14 | ["check bad file"] = testMaker.checkFail("x == 1", 1) 15 | ["check good file"] = testMaker.checkPass("x = 1") 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /pkl/builtins/yamllint.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | import "./test/helpers.pkl" 4 | 5 | @Builtins.meta { 6 | category = "Data Formats" 7 | description = "YAML linter" 8 | } 9 | yamllint = new Config.Step { 10 | glob = List("**/*.yml", "**/*.yaml") 11 | check = "yamllint {{ files }}" 12 | tests { 13 | local const testMaker = new helpers.TestMaker { filename = "test.yaml" } 14 | ["check bad file"] = testMaker.checkFail("x: 1\nx: 2\n", 1) 15 | ["check good file"] = testMaker.checkPass("x: 1\n") 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /docs/cli/config/get.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk config get` 4 | 5 | - **Usage**: `hk config get ` 6 | 7 | Get a specific configuration value 8 | 9 | Available keys: jobs, enabled_profiles, disabled_profiles, fail_fast, display_skip_reasons, warnings, exclude, skip_steps, skip_hooks, stage 10 | 11 | ## Arguments 12 | 13 | ### `` 14 | 15 | Configuration key to retrieve 16 | 17 | Available keys: jobs, enabled_profiles, disabled_profiles, fail_fast, display_skip_reasons, warnings, exclude, skip_steps, skip_hooks, stage 18 | -------------------------------------------------------------------------------- /pkl/builtins/hadolint.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | import "./test/helpers.pkl" 4 | 5 | @Builtins.meta { 6 | category = "Infrastructure" 7 | description = "Dockerfile linter" 8 | } 9 | hadolint = new Config.Step { 10 | glob = "**/Dockerfile*" 11 | check = "hadolint {{ files }}" 12 | tests { 13 | local const testMaker = new helpers.TestMaker { filename = "Dockerfile" } 14 | ["check bad file"] = testMaker.checkFail("FROM debian\n", 1) 15 | ["check good file"] = testMaker.checkPass("FROM debian:jessie\n") 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /pkl/builtins/shfmt.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Shell" 6 | description = "Shell formatter" 7 | } 8 | shfmt = new Config.Step { 9 | batch = true 10 | glob = List("**/*.sh", "**/*.bash", "**/*.mksh", "**/*.bats", "**/*.zsh") 11 | stage = "" 12 | check_diff = "shfmt -d {{ files }}" 13 | check_list_files = 14 | """ 15 | files=$(shfmt -l {{ files }}) 16 | if [ -n "$files" ]; then 17 | echo "$files" 18 | exit 1 19 | fi 20 | """ 21 | fix = "shfmt -w {{ files }}" 22 | } 23 | -------------------------------------------------------------------------------- /test/validate.bats: -------------------------------------------------------------------------------- 1 | setup() { 2 | load 'test_helper/common_setup' 3 | _common_setup 4 | } 5 | teardown() { 6 | _common_teardown 7 | } 8 | 9 | @test "validate" { 10 | cat < hk.pkl 11 | amends "$PKL_PATH/Config.pkl" 12 | import "$PKL_PATH/Builtins.pkl" 13 | hooks { 14 | ["pre-commit"] { steps { ["newlines"] = Builtins.newlines } } 15 | ["pre-push"] { steps { ["newlines"] = Builtins.newlines } } 16 | ["fix"] { steps { ["newlines"] = Builtins.newlines } } 17 | ["check"] { steps { ["newlines"] = Builtins.newlines } } 18 | } 19 | EOF 20 | hk validate 21 | } 22 | -------------------------------------------------------------------------------- /pkl/builtins/cargo_clippy.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Rust" 6 | description = "Rust linter" 7 | } 8 | cargo_clippy = new Config.Step { 9 | glob = "**/*.rs" 10 | stage = "" 11 | workspace_indicator = "Cargo.toml" 12 | check = "cargo clippy --manifest-path {{workspace_indicator}} --quiet" 13 | fix = 14 | "cargo clippy --manifest-path {{workspace_indicator}} --fix --allow-dirty --allow-staged --quiet" 15 | check_first = false 16 | env { 17 | ["CARGO_TERM_PROGRESS_WHEN"] = "never" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /pkl/PklProject: -------------------------------------------------------------------------------- 1 | amends "pkl:Project" 2 | 3 | package { 4 | name = "hk" 5 | authors { "jdx <216188+jdx@users.noreply.github.com>" } 6 | version = read?("env:VERSION")?.replaceFirst("\(name)@", "") ?? "0.0.1-SNAPSHOT" 7 | baseUri = "package://pkg.pkl-lang.org/github.com/jdx/hk" 8 | packageZipUrl = "https://github.com/jdx/hk/releases/download/v\(version)/\(name)@\(version).zip" 9 | sourceCode = "https://github.com/jdx/hk" 10 | sourceCodeUrlScheme = "https://github.com/jdx/hk/blob/\(version)/pkl%{path}#%{line}-%{endLine}" 11 | license = "MIT" 12 | description = "pkl code for hk" 13 | } 14 | -------------------------------------------------------------------------------- /pkl/builtins/check_merge_conflict.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | import "./test/helpers.pkl" 4 | 5 | @Builtins.meta { 6 | category = "Special Purpose" 7 | description = "Detect merge conflict markers" 8 | } 9 | check_merge_conflict = new Config.Step { 10 | glob = "**/*" 11 | check = "hk util check-merge-conflict --assume-in-merge {{files}}" 12 | tests { 13 | local const testMaker = new helpers.TestMaker {} 14 | ["check bad file"] = testMaker.checkFail("<<<<<<< HEAD\nconflict\n", 1) 15 | ["check good file"] = testMaker.checkPass("normal line\n") 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /pkl/builtins/taplo.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | import "./test/helpers.pkl" 4 | 5 | @Builtins.meta { 6 | category = "Data Formats" 7 | description = "TOML linter" 8 | } 9 | taplo = new Config.Step { 10 | glob = "**/*.toml" 11 | check = "taplo lint {{ files }}" 12 | tests { 13 | local const testMaker = new helpers.TestMaker { filename = "test.toml" } 14 | ["check bad file"] = testMaker.checkFail("[table]\nkey = 0\nkey = 1\n", 1) 15 | // NB: Formatting is bad, but `lint` doesn't check formatting 16 | ["check good file"] = testMaker.checkPass("[table]\nkey = 0\n") 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/cli/cache/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::Result; 2 | 3 | mod clear; 4 | 5 | /// Manage hk internal cache 6 | #[derive(Debug, clap::Args)] 7 | #[clap(hide = true)] // TODO: unhide if we actually use cache (which we probably will) 8 | pub struct Cache { 9 | #[clap(subcommand)] 10 | command: Commands, 11 | } 12 | 13 | #[derive(Debug, clap::Subcommand)] 14 | enum Commands { 15 | /// Clear the cache directory 16 | Clear(clear::Clear), 17 | } 18 | 19 | impl Cache { 20 | pub async fn run(self) -> Result<()> { 21 | match self.command { 22 | Commands::Clear(cmd) => cmd.run().await, 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/cli/run/commit_msg.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use crate::Result; 4 | use crate::hook_options::HookOptions; 5 | 6 | #[derive(clap::Args)] 7 | #[clap(visible_alias = "cm")] 8 | pub struct CommitMsg { 9 | /// The path to the file that contains the commit message 10 | commit_msg_file: PathBuf, 11 | #[clap(flatten)] 12 | hook: HookOptions, 13 | } 14 | 15 | impl CommitMsg { 16 | pub async fn run(mut self) -> Result<()> { 17 | self.hook 18 | .tctx 19 | .insert("commit_msg_file", &self.commit_msg_file.to_string_lossy()); 20 | self.hook.run("commit-msg").await 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/cli/validate.rs: -------------------------------------------------------------------------------- 1 | use eyre::bail; 2 | 3 | use crate::{Result, config::Config}; 4 | 5 | /// Validate the config file 6 | #[derive(Debug, clap::Args)] 7 | pub struct Validate {} 8 | 9 | impl Validate { 10 | pub async fn run(&self) -> Result<()> { 11 | let config = Config::get()?; 12 | config.validate()?; 13 | if !config.path.exists() { 14 | bail!( 15 | "config file {} does not exist", 16 | xx::file::display_path(&config.path) 17 | ); 18 | } 19 | info!("{} is valid", xx::file::display_path(&config.path)); 20 | Ok(()) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /pkl/builtins/shellcheck.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | import "./test/helpers.pkl" 4 | 5 | @Builtins.meta { 6 | category = "Shell" 7 | description = "Shell script analyzer" 8 | } 9 | shellcheck = new Config.Step { 10 | batch = true 11 | glob = List("**/*.sh", "**/*.bash") 12 | check = "shellcheck {{ files }}" 13 | tests { 14 | local const testMaker = new helpers.TestMaker { 15 | filename = "test.sh" 16 | } 17 | ["check bad file"] = testMaker.checkFail("#!/usr/bin/bash\necho 'oops I'm escaped'", 1) 18 | ["check good file"] = testMaker.checkPass("#!/usr/bin/bash\necho 'oops I'\\''m escaped'\n") 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/cli/usage.rs: -------------------------------------------------------------------------------- 1 | use crate::Result; 2 | use crate::cli::Cli; 3 | use clap::CommandFactory; 4 | 5 | /// Generates a usage spec for the CLI 6 | /// 7 | /// https://usage.jdx.dev 8 | #[derive(Debug, clap::Args)] 9 | #[clap(hide = true, verbatim_doc_comment)] 10 | pub struct Usage {} 11 | 12 | impl Usage { 13 | pub async fn run(&self) -> Result<()> { 14 | let mut cmd = Cli::command(); 15 | let mut buf = vec![]; 16 | clap_usage::generate(&mut cmd, "hk", &mut buf); 17 | let usage = String::from_utf8(buf).unwrap() + "\n" + include_str!("../hk-extras.usage.kdl"); 18 | println!("{}", usage.trim()); 19 | Ok(()) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.taplo.toml: -------------------------------------------------------------------------------- 1 | [[rule]] 2 | formatting.align_entries = false 3 | formatting.reorder_keys = false 4 | include = ["settings.toml"] 5 | schema.enabled = true 6 | schema.path = "settings-schema.json" 7 | [formatting] 8 | align_entries = true 9 | allowed_blank_lines = 1 10 | array_auto_collapse = false 11 | array_auto_expand = false 12 | array_trailing_comma = true 13 | column_width = 100 14 | compact_arrays = true 15 | compact_inline_tables = false 16 | crlf = false 17 | indent_entries = false 18 | indent_tables = false 19 | reorder_keys = true 20 | trailing_newline = true 21 | -------------------------------------------------------------------------------- /test/arg_escape.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | teardown() { 8 | _common_teardown 9 | } 10 | 11 | @test "arg escape" { 12 | export NO_COLOR=1 13 | cat < hk.pkl 14 | amends "$PKL_PATH/Config.pkl" 15 | import "$PKL_PATH/Builtins.pkl" 16 | hooks { ["pre-commit"] { steps { ["prettier"] = Builtins.prettier } } } 17 | EOF 18 | git add hk.pkl 19 | git commit -m "install hk" 20 | hk install 21 | echo 'console.log("test")' > '$test.js' 22 | git add '$test.js' 23 | run git commit -m "test" 24 | assert_failure 25 | assert_output --partial '[warn] $test.js' 26 | } 27 | -------------------------------------------------------------------------------- /src/cli/completion.rs: -------------------------------------------------------------------------------- 1 | use crate::Result; 2 | 3 | /// Generates shell completion scripts 4 | #[derive(Debug, clap::Args)] 5 | #[clap()] 6 | pub struct Completion { 7 | /// The shell to generate completion for 8 | #[clap()] 9 | shell: String, 10 | } 11 | 12 | impl Completion { 13 | pub async fn run(&self) -> Result<()> { 14 | xx::process::cmd( 15 | "usage", 16 | [ 17 | "g", 18 | "completion", 19 | &self.shell, 20 | "hk", 21 | "--usage-cmd", 22 | "hk usage", 23 | ], 24 | ) 25 | .run()?; 26 | Ok(()) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /benchmark/hk.pkl: -------------------------------------------------------------------------------- 1 | amends "../pkl/Config.pkl" 2 | import "../pkl/Builtins.pkl" 3 | 4 | // defines what happens during git pre-commit hook 5 | local linters = new Mapping {} 6 | 7 | hooks = new { 8 | ["pre-commit"] { 9 | stash = "patch-file" 10 | steps { 11 | ["actionlint"] = Builtins.actionlint 12 | ["cargo-fmt"] = Builtins.cargo_fmt 13 | ["dbg"] { 14 | // ensure no dbg! macros are used 15 | glob = "**/*.rs" 16 | check = "! rg -e 'dbg!' {{files}}" 17 | } 18 | ["prettier"] = (Builtins.prettier) { 19 | glob = List("*.js", "*.ts", "*.yml", "*.yaml") // override the default globs 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /pkl/builtins/fix_byte_order_marker.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | import "./test/helpers.pkl" 4 | 5 | @Builtins.meta { 6 | category = "Special Purpose" 7 | description = "Remove UTF-8 BOM" 8 | } 9 | fix_byte_order_marker = new Config.Step { 10 | glob = "**/*" 11 | stage = "" 12 | fix = "hk util fix-byte-order-marker {{files}}" 13 | tests { 14 | local const testMaker = new helpers.TestMaker {} 15 | ["fix file with BOM"] = testMaker.fixPass("\u{FEFF}Hello, world!", "Hello, world!") 16 | ["fix file without BOM"] = testMaker.fixPass("Hello, world!", "Hello, world!") 17 | ["fix empty file"] = testMaker.fixPass("", "") 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /test/builtins_tests.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | 8 | teardown() { 9 | _common_teardown 10 | } 11 | 12 | @test "builtins tests run" { 13 | cat < hk.pkl 14 | amends "$PKL_PATH/Config.pkl" 15 | import "$PKL_PATH/Builtins.pkl" as Builtins 16 | hooks { 17 | ["check"] { 18 | // Include all Builtins.* steps 19 | steps = Builtins.toMap().toMapping() 20 | } 21 | } 22 | PKL 23 | 24 | PATH="$PATH":"$PROJECT_ROOT"/test/builtin_tool_stubs 25 | run hk test 26 | assert_success 27 | # At least the newlines builtin has a test 28 | assert_output --partial "ok - newlines :: fix bad file" 29 | } 30 | -------------------------------------------------------------------------------- /test/prepare_commit_msg.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | teardown() { 8 | _common_teardown 9 | } 10 | 11 | @test "prepare-commit-msg hook" { 12 | cat < hk.pkl 13 | amends "$PKL_PATH/Config.pkl" 14 | hooks = new { 15 | ["prepare-commit-msg"] { 16 | steps { 17 | ["render-commit-msg"] { 18 | check = "echo default_commit_msg > {{commit_msg_file}}" 19 | } 20 | } 21 | } 22 | } 23 | EOF 24 | hk install 25 | echo "test" > test.txt 26 | git add test.txt 27 | run git commit --no-edit 28 | assert_output --partial "default_commit_msg" 29 | } 30 | -------------------------------------------------------------------------------- /docs/cli/migrate/pre-commit.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk migrate pre-commit` 4 | 5 | - **Usage**: `hk migrate pre-commit [FLAGS]` 6 | 7 | Migrate from pre-commit to hk 8 | 9 | ## Flags 10 | 11 | ### `-c --config ` 12 | 13 | Path to .pre-commit-config.yaml 14 | 15 | **Default:** `.pre-commit-config.yaml` 16 | 17 | ### `-f --force` 18 | 19 | Overwrite existing hk.pkl file 20 | 21 | ### `-o --output ` 22 | 23 | Output path for hk.pkl 24 | 25 | **Default:** `hk.pkl` 26 | 27 | ### `--hk-pkl-root ` 28 | 29 | Root path for hk pkl files (e.g., "pkl" for local, or package URL prefix) If specified, will use {root}/Config.pkl and {root}/Builtins.pkl 30 | -------------------------------------------------------------------------------- /test/hk_test_failure.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | 8 | teardown() { 9 | _common_teardown 10 | } 11 | 12 | @test "hk test surfaces failing tests with non-zero exit" { 13 | cat < hk.pkl 14 | amends "$PKL_PATH/Config.pkl" 15 | hooks { 16 | ["check"] { 17 | steps { 18 | ["demo"] { 19 | check = "sh -c 'echo failing >&2; exit 2'" 20 | tests { 21 | ["fails exits nonzero"] { run = "check" } 22 | } 23 | } 24 | } 25 | } 26 | } 27 | PKL 28 | 29 | run hk test --step demo 30 | assert_failure 31 | assert_output --partial "not ok - demo :: fails exits nonzero" 32 | } 33 | -------------------------------------------------------------------------------- /test/skip_step_flag.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | 8 | teardown() { 9 | _common_teardown 10 | } 11 | 12 | @test "--skip-step skips named step with message" { 13 | cat < hk.pkl 14 | amends "$PKL_PATH/Config.pkl" 15 | display_skip_reasons = List("disabled-by-cli") 16 | hooks { 17 | ["check"] { 18 | steps { 19 | ["foo"] { 20 | check = "echo 'RUN'" 21 | } 22 | } 23 | } 24 | } 25 | EOF 26 | run hk check --skip-step foo 27 | assert_success 28 | assert_output --partial "foo – skipped: disabled via --skip-step foo" 29 | refute_output --partial "RUN" 30 | } 31 | -------------------------------------------------------------------------------- /docs/cli/config.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk config` 4 | 5 | - **Usage**: `hk config ` 6 | - **Aliases**: `cfg` 7 | 8 | Configuration introspection and management 9 | 10 | View and inspect hk's configuration from all sources. Configuration is merged from multiple sources in precedence order: CLI flags > Environment variables > Git config (local) > User config (.hkrc.pkl) > Git config (global) > Project config (hk.pkl) > Built-in defaults. 11 | 12 | ## Subcommands 13 | 14 | - [`hk config dump [--format ]`](/cli/config/dump.md) 15 | - [`hk config explain `](/cli/config/explain.md) 16 | - [`hk config get `](/cli/config/get.md) 17 | - [`hk config sources`](/cli/config/sources.md) 18 | -------------------------------------------------------------------------------- /src/version.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::Ordering; 2 | 3 | use crate::Result; 4 | use eyre::bail; 5 | pub fn version() -> &'static str { 6 | env!("CARGO_PKG_VERSION") 7 | } 8 | 9 | pub fn version_cmp(version: &str) -> Result { 10 | let version = semver::Version::parse(version)?; 11 | let current = semver::Version::parse(env!("CARGO_PKG_VERSION"))?; 12 | Ok(version.cmp(¤t)) 13 | } 14 | 15 | pub fn version_cmp_or_bail(v: &str) -> Result<()> { 16 | match version_cmp(v) { 17 | Ok(Ordering::Greater) => { 18 | bail!( 19 | "hk version {v} is less than the minimum required version {}", 20 | version() 21 | ); 22 | } 23 | _ => Ok(()), 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /pkl/builtins/fix_smart_quotes.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | import "./test/helpers.pkl" 4 | 5 | @Builtins.meta { 6 | category = "Special Purpose" 7 | description = "Replace smart quotes with regular quotes" 8 | } 9 | fix_smart_quotes = new Config.Step { 10 | glob = "**/*" 11 | fix = "hk util fix-smart-quotes {{files}}" 12 | stage = "" 13 | tests { 14 | local const testMaker = new helpers.TestMaker {} 15 | ["fix file with smart quotes"] = 16 | testMaker.fixPass("\u{FF02}Hello, world!\u{FF02}", "\"Hello, world!\"") 17 | ["fix file without smart quotes"] = testMaker.fixPass("\"Hello, world!\"", "\"Hello, world!\"") 18 | ["fix empty file"] = testMaker.fixPass("", "") 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /test/builtin_json.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | 8 | teardown() { 9 | _common_teardown 10 | } 11 | 12 | @test "builtin: json" { 13 | cat < hk.pkl 14 | amends "$PKL_PATH/Config.pkl" 15 | import "$PKL_PATH/Builtins.pkl" 16 | hooks { 17 | ["pre-commit"] { 18 | fix = true 19 | stash = "patch-file" 20 | steps { 21 | ["jq"] = Builtins.jq 22 | } 23 | } 24 | } 25 | EOF 26 | git add hk.pkl 27 | git commit -m "init" 28 | cat < test.json 29 | { "invalid": 30 | EOF 31 | git add test.json 32 | run hk run pre-commit 33 | assert_failure 34 | assert_output --partial "parse error" 35 | } 36 | -------------------------------------------------------------------------------- /test/hk_pkl_http_proxy.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | 8 | teardown() { 9 | _common_teardown 10 | } 11 | 12 | @test "hide warnings: HK_HIDE_WARNINGS=missing-profiles suppresses profile skip warning" { 13 | cat < hk.pkl 14 | amends "package://example.com/v1.26.0/hk@1.26.0#/Config.pkl" 15 | hooks { 16 | ["check"] { 17 | steps { 18 | ["step"] { 19 | check = "echo 'I ran'" 20 | } 21 | } 22 | } 23 | } 24 | EOF 25 | 26 | HK_PKL_HTTP_REWRITE="https://example.com/=https://github.com/jdx/hk/releases/download/" run hk check 27 | assert_success 28 | assert_output --partial "I ran" 29 | } 30 | -------------------------------------------------------------------------------- /test/builtin_json_format.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | 8 | teardown() { 9 | _common_teardown 10 | } 11 | 12 | @test "builtin: json format" { 13 | cat < hk.pkl 14 | amends "$PKL_PATH/Config.pkl" 15 | import "$PKL_PATH/Builtins.pkl" 16 | hooks { 17 | ["pre-commit"] { 18 | fix = true 19 | stash = "git" 20 | steps { 21 | ["jq"] = Builtins.jq 22 | } 23 | } 24 | } 25 | EOF 26 | git add hk.pkl 27 | git commit -m "init" 28 | cat < test.json 29 | {"test": 123} 30 | EOF 31 | git add test.json 32 | hk run pre-commit 33 | assert_file_contains test.json '{ 34 | "test": 123 35 | }' 36 | } 37 | -------------------------------------------------------------------------------- /pkl/builtins/no_commit_to_branch.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Special Purpose" 6 | description = "Prevent direct commits to protected branches" 7 | } 8 | no_commit_to_branch = new Config.Step { 9 | check = "hk util no-commit-to-branch" 10 | tests { 11 | ["passes on feature branch"] { 12 | run = "check" 13 | write { ["{{tmp}}/.gitkeep"] = "" } 14 | before = "git init -q && git checkout -q -b feature-branch" 15 | expect { code = 0 } 16 | } 17 | ["fails on main branch"] { 18 | run = "check" 19 | write { ["{{tmp}}/.gitkeep"] = "" } 20 | before = "git init -q && git checkout -q -b main" 21 | expect { code = 1 } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /pkl/builtins/newlines.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | import "./test/helpers.pkl" 4 | 5 | @Builtins.meta { 6 | category = "Special Purpose" 7 | description = "Ensure files end with newline" 8 | } 9 | newlines = new Config.Step { 10 | glob = "**/*" 11 | stage = "" 12 | check_list_files = "hk util end-of-file-fixer {{files}}" 13 | fix = "hk util end-of-file-fixer --fix {{files}}" 14 | tests { 15 | local const testMaker = new helpers.TestMaker {} 16 | ["check bad file"] = testMaker.checkFail("content", 1) 17 | ["check good file"] = testMaker.checkPass("content\n") 18 | ["fix bad file"] = testMaker.fixPass("content", "content\n") 19 | ["fix good file"] = testMaker.fixPass("content\n", "content\n") 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /pkl/builtins/tombi.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | import "./test/helpers.pkl" 4 | 5 | @Builtins.meta { 6 | category = "Data Formats" 7 | description = "TOML linter" 8 | } 9 | tombi = new Config.Step { 10 | glob = "**/*.toml" 11 | check = "tombi lint {{ files }}" 12 | tests { 13 | local const testMaker = new helpers.TestMaker { 14 | filename = "file.toml" 15 | extra_files = new Mapping { 16 | ["tombi.toml"] = "toml-version = \"v1.0.0\"\n" 17 | } 18 | } 19 | // NB: Formatting is bad, but `tombi lint` doesn't check formatting 20 | ["check bad file"] = testMaker.checkFail("[table]\nkey = 0\nkey = 1\n", 1) 21 | ["check good file"] = testMaker.checkPass("[table]\nkey = 0\n") 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "clx"] 2 | path = clx 3 | url = https://github.com/jdx/clx.git 4 | [submodule "ensembler"] 5 | path = ensembler 6 | url = https://github.com/jdx/ensembler.git 7 | [submodule "xx"] 8 | path = xx 9 | url = https://github.com/jdx/xx.git 10 | [submodule "test/test_helper/bats-support"] 11 | path = test/test_helper/bats-support 12 | url = https://github.com/bats-core/bats-support.git 13 | [submodule "test/test_helper/bats-assert"] 14 | path = test/test_helper/bats-assert 15 | url = https://github.com/bats-core/bats-assert.git 16 | [submodule "test/bats"] 17 | path = test/bats 18 | url = https://github.com/bats-core/bats-core.git 19 | [submodule "test/test_helper/bats-file"] 20 | path = test/test_helper/bats-file 21 | url = https://github.com/bats-core/bats-file.git 22 | -------------------------------------------------------------------------------- /test/hk_test_env.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | 8 | teardown() { 9 | _common_teardown 10 | } 11 | 12 | @test "hk test supports StepTest.env and overrides step env" { 13 | cat < hk.pkl 14 | amends "$PKL_PATH/Config.pkl" 15 | hooks { 16 | ["check"] { 17 | steps { 18 | ["demo"] { 19 | check = "echo \$FOO:\$BAR" 20 | env { ["BAR"] = "baz" } 21 | tests { 22 | ["env overrides"] { 23 | run = "check" 24 | env { ["FOO"] = "foo"; ["BAR"] = "bar" } 25 | expect { stdout = "foo:bar" } 26 | } 27 | } 28 | } 29 | } 30 | } 31 | } 32 | PKL 33 | 34 | run hk test --step demo 35 | assert_success 36 | } 37 | -------------------------------------------------------------------------------- /pkl/builtins/check_added_large_files.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Special Purpose" 6 | description = "Prevent committing large files" 7 | } 8 | check_added_large_files = new Config.Step { 9 | glob = "**/*" 10 | check = "hk util check-added-large-files {{files}}" 11 | tests { 12 | ["detects large file with default 500KB limit"] { 13 | run = "check" 14 | before = "dd if=/dev/zero of={{tmp}}/large.bin bs=1024 count=501 2>/dev/null" 15 | files = List("{{tmp}}/large.bin") 16 | expect { code = 1 } 17 | } 18 | ["passes small file"] { 19 | run = "check" 20 | write { 21 | ["{{tmp}}/small.txt"] = "small content" 22 | } 23 | expect { code = 0 } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /test/run_pre_commit_all.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | 8 | teardown() { 9 | _common_teardown 10 | } 11 | 12 | @test "hk run pre-commit --all runs on all files" { 13 | cat < test.js 14 | console.log("test") 15 | EOF 16 | git add test.js 17 | git commit -m init 18 | cat < hk.pkl 19 | amends "$PKL_PATH/Config.pkl" 20 | import "$PKL_PATH/Builtins.pkl" 21 | hooks { 22 | ["pre-commit"] { 23 | fix = true 24 | stash = "git" 25 | steps { 26 | ["prettier"] = Builtins.prettier 27 | } 28 | } 29 | } 30 | EOF 31 | hk run pre-commit --all 32 | assert_file_exists hk.pkl 33 | run cat test.js 34 | assert_output 'console.log("test");' 35 | } 36 | -------------------------------------------------------------------------------- /pkl/builtins/tsc.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | import "./test/helpers.pkl" 4 | 5 | @Builtins.meta { 6 | category = "JavaScript/TypeScript" 7 | description = "TypeScript type checker" 8 | } 9 | tsc = new Config.Step { 10 | workspace_indicator = "tsconfig.json" 11 | glob = List("**/*.ts", "**/*.tsx") 12 | check = "tsc --noEmit -p {{workspace_indicator}}" 13 | tests { 14 | local const testMaker = new helpers.TestMaker { 15 | filename = "src/test.ts" 16 | extra_files = new Mapping { 17 | ["tsconfig.json"] = "{\"compilerOptions\": {\"strict\": true}}" 18 | } 19 | } 20 | ["check bad file"] = testMaker.checkFail("const x: number = 'hello';", 2) 21 | ["check good file"] = testMaker.checkPass("const x: number = 1;") 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hk", 3 | "version": "1.0.0", 4 | "description": "A tool for running hooks on files in a git repository.", 5 | "main": "index.js", 6 | "workspaces": [ 7 | "docs" 8 | ], 9 | "directories": { 10 | "doc": "docs", 11 | "test": "test" 12 | }, 13 | "scripts": { 14 | "test": "echo \"Error: no test specified\" && exit 1" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/jdx/hk.git" 19 | }, 20 | "author": "", 21 | "license": "ISC", 22 | "bugs": { 23 | "url": "https://github.com/jdx/hk/issues" 24 | }, 25 | "homepage": "https://github.com/jdx/hk#readme", 26 | "dependencies": { 27 | "eslint": "^9.21.0", 28 | "typescript": "^5.8.2" 29 | }, 30 | "devDependencies": { 31 | "globals": "^16.0.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /pkl/builtins/check_case_conflict.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Special Purpose" 6 | description = "Detect case-insensitive filename conflicts" 7 | } 8 | check_case_conflict = new Config.Step { 9 | glob = "**/*" 10 | check = "hk util check-case-conflict {{files}}" 11 | tests { 12 | ["detects case conflicts"] { 13 | run = "check" 14 | write { 15 | ["{{tmp}}/README.md"] = "# First\n" 16 | ["{{tmp}}/readme.md"] = "# Second\n" 17 | } 18 | expect { code = 1 } 19 | } 20 | ["passes when no conflicts"] { 21 | run = "check" 22 | write { 23 | ["{{tmp}}/file1.txt"] = "content\n" 24 | ["{{tmp}}/file2.txt"] = "content\n" 25 | } 26 | expect { code = 0 } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /pkl/builtins/trailing_whitespace.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | import "./test/helpers.pkl" 4 | 5 | @Builtins.meta { 6 | category = "Special Purpose" 7 | description = "Detect and remove trailing whitespace" 8 | } 9 | trailing_whitespace = new Config.Step { 10 | glob = "**/*" 11 | stage = "" 12 | check_list_files = "hk util trailing-whitespace {{files}}" 13 | fix = "hk util trailing-whitespace --fix {{files}}" 14 | tests { 15 | local const testMaker = new helpers.TestMaker {} 16 | ["check bad file"] = testMaker.checkFail("trailing \n", 1) 17 | ["check good file"] = testMaker.checkPass("trailing\n") 18 | ["fix bad file"] = testMaker.fixPass("trailing \n", "trailing\n") 19 | ["fix good file"] = testMaker.fixPass("trailing\n", "trailing\n") 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /test/commit_msg.bats: -------------------------------------------------------------------------------- 1 | setup() { 2 | load 'test_helper/common_setup' 3 | _common_setup 4 | } 5 | teardown() { 6 | _common_teardown 7 | } 8 | 9 | @test "commit-msg hook" { 10 | cat < hk.pkl 11 | amends "$PKL_PATH/Config.pkl" 12 | hooks = new { 13 | ["commit-msg"] { 14 | steps { 15 | ["validate-commit-msg"] { 16 | check = "grep -q '^feat: ' {{commit_msg_file}} || (echo 'Commit message must start with feat:' >&2 && exit 1)" 17 | } 18 | } 19 | } 20 | } 21 | EOF 22 | hk install 23 | echo "test" > test.txt 24 | git add test.txt 25 | run git commit -m "test" 26 | assert_failure 27 | assert_output --partial "Commit message must start with feat:" 28 | 29 | run git commit -m "feat: add test file" 30 | assert_success 31 | } 32 | -------------------------------------------------------------------------------- /test/condition.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | teardown() { 8 | _common_teardown 9 | } 10 | 11 | @test "condition" { 12 | cat < hk.pkl 13 | amends "$PKL_PATH/Config.pkl" 14 | import "$PKL_PATH/Builtins.pkl" 15 | hooks { 16 | ["fix"] { 17 | steps { 18 | ["a"] { fix = "echo ITWORKS > a.txt"; condition = "true" } 19 | ["b"] { fix = "echo ITWORKS > b.txt"; condition = "false" } 20 | ["c"] { fix = "echo ITWORKS > c.txt"; condition = "exec('echo ITWORKS') == 'ITWORKS\n'" } 21 | } 22 | } 23 | } 24 | EOF 25 | git add hk.pkl 26 | git commit -m "initial commit" 27 | hk fix -v 28 | assert_file_exists a.txt 29 | assert_file_not_exists b.txt 30 | assert_file_exists c.txt 31 | } 32 | -------------------------------------------------------------------------------- /pkl/builtins/black.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | import "./test/helpers.pkl" 4 | 5 | @Builtins.meta { 6 | category = "Python" 7 | description = "Opinionated Python formatter" 8 | } 9 | black = new Config.Step { 10 | glob = List("**/*.py") 11 | stage = "" 12 | check = "black --check {{ files }}" 13 | check_diff = "black --check --diff {{ files }}" 14 | fix = "black {{ files }}" 15 | tests { 16 | local const testMaker = new helpers.TestMaker { filename = "test.py" } 17 | local const before = "x=1" 18 | local const after = "x = 1\n" 19 | ["check bad file"] = testMaker.checkFail(before, 1) 20 | ["check good file"] = testMaker.checkPass(after) 21 | ["fix bad file"] = testMaker.fixPass(before, after) 22 | ["fix good file"] = testMaker.fixPass(after, after) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /pkl/builtins/python_check_ast.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | import "./test/helpers.pkl" 4 | 5 | @Builtins.meta { 6 | category = "Python" 7 | description = "Validate Python syntax by parsing the AST" 8 | } 9 | python_check_ast = new Config.Step { 10 | glob = "**/*.py" 11 | check = "hk util python-check-ast {{files}}" 12 | tests { 13 | local const testMaker = new helpers.TestMaker { filename = "test.py" } 14 | ["check bad file"] = 15 | testMaker.checkFail( 16 | """ 17 | def hello(: 18 | print("Invalid" 19 | """, 20 | 1, 21 | ) 22 | ["check good file"] = 23 | testMaker.checkPass( 24 | """ 25 | def hello(): 26 | print("Hello, world!") 27 | """, 28 | ) 29 | ["check empty file"] = testMaker.checkPass("") 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /test/check_fix_suggestion_fix_mode.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | 8 | teardown() { 9 | _common_teardown 10 | } 11 | 12 | @test "no fix suggestion on fix run even if check_first triggers check" { 13 | cat < hk.pkl 14 | amends "$PKL_PATH/Config.pkl" 15 | hooks { 16 | ["fix"] { 17 | steps { 18 | ["fmt"] { 19 | check_first = true 20 | // Failing check 21 | check = "sh -c 'echo check failed >&2; exit 1'" 22 | // Define a simple fix command 23 | fix = "echo fix {{files}}" 24 | } 25 | } 26 | } 27 | } 28 | EOF 29 | 30 | echo "x" > a.js 31 | 32 | run hk fix a.js 33 | # The overall run is fix; it should not print a suggestion that starts with "To fix, run:" 34 | refute_output --partial "To fix, run:" 35 | } 36 | -------------------------------------------------------------------------------- /pkl/builtins/check_byte_order_marker.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Special Purpose" 6 | description = "Detect UTF-8 BOM" 7 | } 8 | check_byte_order_marker = new Config.Step { 9 | glob = "**/*" 10 | check = "hk util check-byte-order-marker {{files}}" 11 | tests { 12 | ["detects BOM"] { 13 | run = "check" 14 | write { 15 | ["{{tmp}}/with_bom.txt"] = "\u{FEFF}Hello, world!" 16 | } 17 | expect { code = 1 } 18 | } 19 | ["passes without BOM"] { 20 | run = "check" 21 | write { 22 | ["{{tmp}}/without_bom.txt"] = "Hello, world!" 23 | } 24 | expect { code = 0 } 25 | } 26 | ["passes empty file"] { 27 | run = "check" 28 | write { 29 | ["{{tmp}}/empty.txt"] = "" 30 | } 31 | expect { code = 0 } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /test/git_runs_pre_commit_on_staged_files.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | 8 | teardown() { 9 | _common_teardown 10 | } 11 | 12 | @test "git runs pre-commit on staged files" { 13 | cat < hk.pkl 14 | amends "$PKL_PATH/Config.pkl" 15 | import "$PKL_PATH/Builtins.pkl" 16 | hooks { 17 | ["pre-commit"] { 18 | fix = true 19 | stash = "git" 20 | steps { 21 | ["prettier"] = Builtins.prettier 22 | } 23 | } 24 | } 25 | EOF 26 | git add hk.pkl 27 | git commit -m "init" 28 | cat < test.js 29 | console.log("test") 30 | EOF 31 | run git add test.js 32 | hk install 33 | run cat test.js 34 | assert_output 'console.log("test")' 35 | git commit -m "test" 36 | run cat test.js 37 | assert_output 'console.log("test");' 38 | } 39 | -------------------------------------------------------------------------------- /test/skip_hook.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | 8 | teardown() { 9 | _common_teardown 10 | } 11 | 12 | @test "HK_SKIP_HOOK skips entire hooks" { 13 | cat < hk.pkl 14 | amends "$PKL_PATH/Config.pkl" 15 | import "$PKL_PATH/Builtins.pkl" 16 | hooks { 17 | ["pre-commit"] { 18 | fix = true 19 | stash = "patch-file" 20 | steps { 21 | ["prettier"] = Builtins.prettier 22 | ["newlines"] = Builtins.newlines 23 | } 24 | } 25 | } 26 | EOF 27 | git add hk.pkl 28 | git commit -m "init" 29 | touch test.sh 30 | touch test.js 31 | git add test.sh test.js 32 | export HK_SKIP_HOOK="pre-commit" 33 | run hk run pre-commit -v 34 | assert_success 35 | assert_output --partial "pre-commit: skipping hook due to HK_SKIP_HOOK" 36 | } 37 | -------------------------------------------------------------------------------- /pkl/builtins/yq.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | import "./test/helpers.pkl" 4 | 5 | @Builtins.meta { 6 | category = "Data Formats" 7 | description = "YAML processor" 8 | } 9 | yq = new Config.Step { 10 | glob = List("**/*.yaml", "**/*.yml") 11 | stage = "" 12 | check_diff = 13 | """ 14 | failed=0 15 | for f in {{ files }}; do 16 | yq -P "$f" | diff -u "$f" - || failed=1 17 | done 18 | exit $failed 19 | """ 20 | fix = "yq -iP {{ files }}" 21 | tests { 22 | local const testMaker = new helpers.TestMaker { filename = "test.yaml" } 23 | ["check bad file"] = testMaker.checkFail("foo: bar", 1) 24 | ["check good file"] = testMaker.checkPass("foo: bar\n") 25 | ["fix bad file"] = testMaker.fixPass("foo: bar", "foo: bar\n") 26 | ["fix good file"] = testMaker.fixPass("foo: bar\n", "foo: bar\n") 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /mise-tasks/update-version.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eux 3 | 4 | if [[ "$OSTYPE" == "darwin"* ]]; then 5 | if ! command -v gsed &> /dev/null; then 6 | echo "gsed is required on macOS. Install with: brew install gnu-sed" >&2 7 | exit 1 8 | fi 9 | SED="gsed" 10 | else 11 | SED="sed" 12 | fi 13 | 14 | # Find files matching the version pattern 15 | # Explicitly specify current directory (.) to ensure rg searches all files 16 | files=$(rg 'package://github\.com/jdx/hk/releases/download/v[0-9.]+/hk@[0-9.]+#/' --files-with-matches . || true) 17 | 18 | # Update each file if any were found 19 | if [[ -n "$files" ]]; then 20 | echo "$files" | while IFS= read -r file; do 21 | "$SED" -i "s|package://github\.com/jdx/hk/releases/download/v[0-9.]\+/hk@[0-9.]\+#|package://github.com/jdx/hk/releases/download/v$VERSION/hk@$VERSION#|g" "$file" 22 | done 23 | fi 24 | 25 | git add . 26 | -------------------------------------------------------------------------------- /src/cli/uninstall.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use crate::Result; 4 | 5 | /// Removes hk hooks from the current git repository 6 | #[derive(Debug, clap::Args)] 7 | pub struct Uninstall {} 8 | 9 | impl Uninstall { 10 | pub async fn run(&self) -> Result<()> { 11 | let hooks = PathBuf::from(".git/hooks"); 12 | for p in xx::file::ls(&hooks)? { 13 | let content = match xx::file::read_to_string(&p) { 14 | Ok(content) => content, 15 | Err(e) => { 16 | debug!("failed to read hook: {e}"); 17 | continue; 18 | } 19 | }; 20 | let is_hk_hook = content.contains("hk run"); 21 | if is_hk_hook { 22 | xx::file::remove_file(&p)?; 23 | info!("removed hook: {}", xx::file::display_path(&p)); 24 | } 25 | } 26 | Ok(()) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test/uninstall.bats: -------------------------------------------------------------------------------- 1 | setup() { 2 | load 'test_helper/common_setup' 3 | _common_setup 4 | } 5 | teardown() { 6 | _common_teardown 7 | } 8 | 9 | @test "uninstall" { 10 | cat < hk.pkl 11 | amends "$PKL_PATH/Config.pkl" 12 | import "$PKL_PATH/Builtins.pkl" 13 | hooks { 14 | ["pre-commit"] { steps { ["newlines"] = Builtins.newlines } } 15 | ["pre-push"] { steps { ["newlines"] = Builtins.newlines } } 16 | ["fix"] { steps { ["newlines"] = Builtins.newlines } } 17 | ["check"] { steps { ["newlines"] = Builtins.newlines } } 18 | } 19 | EOF 20 | rm -f .git/hooks/* 21 | hk install 22 | assert_file_exists .git/hooks/pre-commit 23 | assert_file_exists .git/hooks/pre-push 24 | assert_file_not_exists .git/hooks/fix 25 | assert_file_not_exists .git/hooks/check 26 | hk uninstall 27 | assert_file_not_exists .git/hooks/pre-commit 28 | assert_file_not_exists .git/hooks/pre-push 29 | } 30 | -------------------------------------------------------------------------------- /pkl/builtins/swiftlint.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | import "./test/helpers.pkl" 4 | 5 | @Builtins.meta { 6 | category = "Other Languages" 7 | description = "Swift style and conventions" 8 | } 9 | swiftlint = new Config.Step { 10 | glob = "**/*.swift" 11 | stage = "" 12 | check = "swiftlint lint {{ files }}" 13 | fix = "swiftlint --fix {{ files }}" 14 | tests { 15 | local const testMaker = new helpers.TestMaker { 16 | filename = "test.swift" 17 | extra_files = new Mapping { 18 | [".swiftlint.yml"] = "strict: true\n" 19 | } 20 | } 21 | ["check bad file"] = testMaker.checkFail("let abc:Void\n", 2) 22 | ["check good file"] = testMaker.checkPass("let abc: Void\n") 23 | ["fix bad file"] = testMaker.fixPass("let abc:Void\n", "let abc: Void\n") 24 | ["fix good file"] = testMaker.fixPass("let abc: Void\n", "let abc: Void\n") 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /pkl/builtins/taplo_format.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | import "./test/helpers.pkl" 4 | 5 | @Builtins.meta { 6 | category = "Data Formats" 7 | description = "TOML formatter" 8 | } 9 | taplo_format = new Config.Step { 10 | glob = "**/*.toml" 11 | stage = "" 12 | check = "taplo format --check {{ files }}" 13 | check_diff = "taplo format --check --diff {{ files }}" 14 | fix = "taplo format {{ files }}" 15 | tests { 16 | local const testMaker = new helpers.TestMaker { filename = "test.toml" } 17 | ["check bad file"] = testMaker.checkFail("[table]\nkey = \"value\"\n", 1) 18 | ["check good file"] = testMaker.checkPass("[table]\nkey = \"value\"\n") 19 | ["fix bad file"] = 20 | testMaker.fixPass("[table]\nkey = \"value\"\n", "[table]\nkey = \"value\"\n") 21 | ["fix good file"] = 22 | testMaker.fixPass("[table]\nkey = \"value\"\n", "[table]\nkey = \"value\"\n") 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /pkl/builtins/yamlfmt.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | import "./test/helpers.pkl" 4 | 5 | @Builtins.meta { 6 | category = "Data Formats" 7 | description = "YAML formatter" 8 | } 9 | yamlfmt = new Config.Step { 10 | glob = List("**/*.yml", "**/*.yaml") 11 | stage = "" 12 | check_diff = "yamlfmt -lint {{ files }}" 13 | fix = "yamlfmt {{ files }}" 14 | tests { 15 | local const testMaker = new helpers.TestMaker { 16 | filename = "test.yaml" 17 | extra_files = new Mapping { 18 | ["yamlfmt.yaml"] = 19 | """ 20 | formatter: 21 | type: basic 22 | 23 | """ 24 | } 25 | } 26 | ["check bad file"] = testMaker.checkFail("- a\n", 1) 27 | ["check good file"] = testMaker.checkPass("- a\n") 28 | ["fix bad file"] = testMaker.fixPass("- a\n", "- a\n") 29 | ["fix good file"] = testMaker.fixPass("- a\n", "- a\n") 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /pkl/builtins/mixed_line_ending.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | import "./test/helpers.pkl" 4 | 5 | @Builtins.meta { 6 | category = "Special Purpose" 7 | description = "Detect and fix mixed line endings" 8 | } 9 | mixed_line_ending = new Config.Step { 10 | glob = "**/*" 11 | stage = "" 12 | check = "hk util mixed-line-ending {{files}}" 13 | fix = "hk util mixed-line-ending --fix {{files}}" 14 | tests { 15 | local const testMaker = new helpers.TestMaker {} 16 | ["check bad file"] = testMaker.checkFail("line1\r\nline2\nline3\r\n", 1) 17 | ["check good file (LF only)"] = testMaker.checkPass("line1\nline2\nline3\n") 18 | ["check good file (CRLF only)"] = testMaker.checkPass("line1\r\nline2\r\nline3\r\n") 19 | ["fix bad file"] = testMaker.fixPass("line1\r\nline2\nline3\r\n", "line1\nline2\nline3\n") 20 | ["fix good file"] = testMaker.fixPass("line1\nline2\nline3\n", "line1\nline2\nline3\n") 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /test/hk_test_project_paths.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | 8 | teardown() { 9 | _common_teardown 10 | } 11 | 12 | @test "hk test can use absolute project paths" { 13 | # hk.pkl defines a step that reads a file via absolute path under the project 14 | cat < hk.pkl 15 | amends "$PKL_PATH/Config.pkl" 16 | hooks { 17 | ["check"] { 18 | steps { 19 | ["demo"] { 20 | check = "cat {{ files }}" 21 | tests { 22 | ["uses project file"] { 23 | write { ["{{root}}/project.txt"] = "hello from project" } 24 | files = List("{{root}}/project.txt") 25 | run = "check" 26 | expect { stdout = "hello from project" } 27 | } 28 | } 29 | } 30 | } 31 | } 32 | } 33 | PKL 34 | 35 | run hk test --step demo 36 | assert_success 37 | assert_output --partial "ok - demo :: uses project file" 38 | } 39 | -------------------------------------------------------------------------------- /test/skip_steps.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | 8 | teardown() { 9 | _common_teardown 10 | } 11 | 12 | @test "HK_SKIP_STEPS skips specified steps" { 13 | cat < hk.pkl 14 | amends "$PKL_PATH/Config.pkl" 15 | import "$PKL_PATH/Builtins.pkl" 16 | display_skip_reasons = List("disabled-by-env") 17 | hooks { 18 | ["pre-commit"] { 19 | fix = true 20 | stash = "git" 21 | steps { 22 | ["prettier"] = Builtins.prettier 23 | ["newlines"] = Builtins.newlines 24 | } 25 | } 26 | } 27 | EOF 28 | git add hk.pkl 29 | git commit -m "init" 30 | touch test.sh 31 | touch test.js 32 | git add test.sh test.js 33 | export HK_SKIP_STEPS="newlines" 34 | run hk run pre-commit -v 35 | assert_success 36 | assert_output --partial "prettier" 37 | assert_output --partial "newlines – skipped: disabled via HK_SKIP_STEPS" 38 | } 39 | -------------------------------------------------------------------------------- /src/cli/run/prepare_commit_msg.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use crate::Result; 4 | use crate::hook_options::HookOptions; 5 | 6 | #[derive(clap::Args)] 7 | #[clap(visible_alias = "pcm")] 8 | pub struct PrepareCommitMsg { 9 | /// The path to the file that contains the commit message so far 10 | commit_msg_file: PathBuf, 11 | /// The source of the commit message (e.g., "message", "template", "merge") 12 | source: Option, 13 | /// The SHA of the commit being amended (if applicable) 14 | sha: Option, 15 | #[clap(flatten)] 16 | hook: HookOptions, 17 | } 18 | 19 | impl PrepareCommitMsg { 20 | pub async fn run(mut self) -> Result<()> { 21 | self.hook 22 | .tctx 23 | .insert("commit_msg_file", &self.commit_msg_file.to_string_lossy()); 24 | self.hook.tctx.insert("source", &self.source); 25 | self.hook.tctx.insert("sha", &self.sha.as_ref()); 26 | self.hook.run("prepare-commit-msg").await 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test/hk_test_files_default.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | 8 | teardown() { 9 | _common_teardown 10 | } 11 | 12 | @test "hk test defaults files to globbed write keys" { 13 | cat < hk.pkl 14 | amends "$PKL_PATH/Config.pkl" 15 | hooks { 16 | ["check"] { 17 | steps { 18 | ["demo"] { 19 | glob = "*.txt" 20 | exclude = "excluded.txt" 21 | check = "echo {{files}} && exit 1" 22 | tests { 23 | ["omits files"] { 24 | run = "check" 25 | write { 26 | ["{{tmp}}/test.txt"] = "content" 27 | ["{{tmp}}/excluded.txt"] = "content" 28 | ["{{tmp}}/test.config"] = "content" 29 | } 30 | expect { code = 0 } 31 | } 32 | } 33 | } 34 | } 35 | } 36 | } 37 | PKL 38 | 39 | run hk test --step demo 40 | assert_failure 41 | assert_output --partial "/test.txt" 42 | } 43 | -------------------------------------------------------------------------------- /test/builtins.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | setup() { 3 | load 'test_helper/common_setup' 4 | _common_setup 5 | } 6 | teardown() { 7 | _common_teardown 8 | } 9 | 10 | @test "builtins lists all available builtin linters" { 11 | run hk builtins 12 | 13 | assert_success 14 | assert_line --regexp '^actionlint$' 15 | assert_line --regexp '^prettier$' 16 | assert_line --regexp '^rustfmt$' 17 | assert_line --regexp '^yamllint$' 18 | } 19 | 20 | @test "builtins output is sorted" { 21 | run hk builtins 22 | 23 | assert_success 24 | # Check that the output is sorted alphabetically 25 | assert_output --partial 'actionlint' 26 | assert_output --partial 'prettier' 27 | assert_output --partial 'rustfmt' 28 | assert_output --partial 'yamllint' 29 | } 30 | 31 | @test "builtins works in any directory" { 32 | cd "$TEST_TEMP_DIR" 33 | run hk builtins 34 | 35 | assert_success 36 | assert_line --regexp '^actionlint$' 37 | assert_line --regexp '^prettier$' 38 | } 39 | -------------------------------------------------------------------------------- /test/skip_missing_run_cmd.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | 8 | teardown() { 9 | _common_teardown 10 | } 11 | 12 | @test "dependent step proceeds when dependency has no command for run type" { 13 | cat < hk.pkl 14 | amends "$PKL_PATH/Config.pkl" 15 | display_skip_reasons = List("no-command-for-run-type") 16 | hooks { 17 | ["check"] { 18 | steps { 19 | ["only_fix"] { 20 | // Has only a fix command, so for `hk check` there is no run command 21 | fix = "echo 'WILL_NOT_RUN'" 22 | } 23 | ["needs_dep"] { 24 | depends = List("only_fix") 25 | check = "echo 'RUN'" 26 | } 27 | } 28 | } 29 | } 30 | EOF 31 | run hk check 32 | assert_success 33 | assert_output --partial "only_fix – skipped: no command for run type" 34 | assert_output --partial "RUN" 35 | refute_output --partial "WILL_NOT_RUN" 36 | } 37 | -------------------------------------------------------------------------------- /pkl/builtins/check_symlinks.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Special Purpose" 6 | description = "Detect broken symlinks" 7 | } 8 | check_symlinks = new Config.Step { 9 | glob = "**/*" 10 | allow_symlinks = true 11 | check = "hk util check-symlinks {{files}}" 12 | tests { 13 | ["detects broken symlink"] { 14 | run = "check" 15 | write { ["{{tmp}}/target.txt"] = "content" } 16 | before = "ln -s {{tmp}}/nonexistent {{tmp}}/broken_link" 17 | files = List("{{tmp}}/broken_link") 18 | expect { code = 1 } 19 | } 20 | ["passes valid symlink"] { 21 | run = "check" 22 | write { ["{{tmp}}/target.txt"] = "content" } 23 | before = "ln -s {{tmp}}/target.txt {{tmp}}/link" 24 | files = List("{{tmp}}/link") 25 | expect { code = 0 } 26 | } 27 | ["passes regular file"] { 28 | run = "check" 29 | write { ["{{tmp}}/file.txt"] = "content" } 30 | expect { code = 0 } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.github/workflows/release-plz.yml: -------------------------------------------------------------------------------- 1 | name: release-plz 2 | 3 | permissions: 4 | pull-requests: write 5 | contents: write 6 | 7 | on: 8 | workflow_dispatch: {} 9 | push: 10 | branches: 11 | - main 12 | - release-plz 13 | 14 | concurrency: 15 | group: release-plz 16 | 17 | env: 18 | DRY_RUN: 0 19 | 20 | jobs: 21 | release-plz: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 25 | with: 26 | fetch-depth: 0 27 | submodules: recursive 28 | token: ${{ secrets.HK_GH_TOKEN }} 29 | - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 30 | - uses: jdx/mise-action@146a28175021df8ca24f8ee1828cc2a60f980bd5 # v3 31 | with: 32 | experimental: true 33 | - run: mise trust --all 34 | - run: mise run release-plz 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.HK_GH_TOKEN }} 37 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 38 | -------------------------------------------------------------------------------- /pkl/builtins/ktlint.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | import "./test/helpers.pkl" 4 | 5 | @Builtins.meta { 6 | category = "Other Languages" 7 | description = "Kotlin linter and formatter" 8 | } 9 | ktlint = new Config.Step { 10 | glob = "**/*.kt" 11 | stage = "" 12 | check = "ktlint {{ files }}" 13 | fix = "ktlint -F {{ files }}" 14 | tests { 15 | local const testMaker = new helpers.TestMaker { filename = "Test.kt" } 16 | ["check bad file"] = testMaker.checkFail("const val FOO_1 = \"foo1\"", 1) 17 | ["check good file"] = testMaker.checkPass("const val FOO_1 = \"foo1\"\n") 18 | ["fix bad file"] = 19 | testMaker.fixPass("const val FOO_1 = \"foo1\"", "const val FOO_1 = \"foo1\"\n") 20 | ["fix good file"] = 21 | testMaker.fixPass("const val FOO_1 = \"foo1\"\n", "const val FOO_1 = \"foo1\"\n") 22 | ["fixes formatting but not unfixable lint error"] = 23 | testMaker.fixFail("const val fooBar = \"FOO-BAR\"", "const val fooBar = \"FOO-BAR\"\n", 1) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to hk 2 | 3 | Thank you for your interest in contributing to hk! This guide will help you get started. 4 | 5 | ## Prerequisites 6 | 7 | - [mise](https://mise.jdx.dev/) 8 | - [Rust](https://www.rust-lang.org/) 9 | 10 | ## Setup 11 | 12 | 1. Clone the repository: 13 | 14 | ```sh 15 | git clone --recurse-submodules https://github.com/jdx/hk.git 16 | cd hk 17 | ``` 18 | 19 | 2. Install required tools and dependencies: 20 | 21 | ```sh 22 | mise install 23 | ``` 24 | 25 | ## Running Tests 26 | 27 | To run the test suite, use the following command: 28 | 29 | ```sh 30 | mise run test 31 | ``` 32 | 33 | This will run all tests, including Bats shell tests and any other checks defined in the project. 34 | 35 | To run a specific test, use the following command: 36 | 37 | ```sh 38 | mise run test:bats -- test/workspace_indicator.bats 39 | ``` 40 | 41 | ## Code Style 42 | 43 | Check/format code with hk: 44 | 45 | ```sh 46 | hk fix --all 47 | ``` 48 | 49 | Or with the mise task: 50 | 51 | ```sh 52 | mise lint-fix 53 | ``` 54 | -------------------------------------------------------------------------------- /pkl/builtins/tombi_format.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | import "./test/helpers.pkl" 4 | 5 | @Builtins.meta { 6 | category = "Data Formats" 7 | description = "TOML formatter" 8 | } 9 | tombi_format = new Config.Step { 10 | glob = "**/*.toml" 11 | stage = "" 12 | check_diff = "tombi format --check --diff {{ files }}" 13 | fix = "tombi format {{ files }}" 14 | tests { 15 | local const testMaker = new helpers.TestMaker { 16 | filename = "test.toml" 17 | extra_files = new Mapping { 18 | ["tombi.toml"] = "toml-version = \"v1.0.0\"\n" 19 | } 20 | } 21 | ["check bad file"] = testMaker.checkFail("[table]\nkey = \"value\"\n", 1) 22 | ["check good file"] = testMaker.checkPass("[table]\nkey = \"value\"\n") 23 | ["fix bad file"] = 24 | testMaker.fixPass("[table]\nkey = \"value\"\n", "[table]\nkey = \"value\"\n") 25 | ["fix good file"] = 26 | testMaker.fixPass("[table]\nkey = \"value\"\n", "[table]\nkey = \"value\"\n") 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /benchmark/.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: local 5 | hooks: 6 | - id: actionlint 7 | name: actionlint 8 | entry: actionlint 9 | language: system 10 | types: [file] 11 | files: \.github/workflows/.*(yml|yaml) 12 | - id: cargo-fmt 13 | name: cargo-fmt 14 | entry: cargo fmt 15 | language: system 16 | types: [file] 17 | files: \.rs$ 18 | pass_filenames: false 19 | - id: dbg 20 | name: dbg 21 | entry: bash -c '! rg -e "dbg!" src/main.rs' # TODO: fix this 22 | language: system 23 | types: [file] 24 | files: \.rs$ 25 | - id: prettier 26 | name: prettier 27 | entry: prettier --write 28 | language: system 29 | types: [file] 30 | files: \.(js|jsx|ts|tsx|css|scss|less|html|json|jsonc|yaml|markdown|markdown\.mdx|graphql|handlebars|svelte|astro|htmlangular)$ 31 | -------------------------------------------------------------------------------- /test/config_pkl_imports.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | teardown() { 8 | _common_teardown 9 | } 10 | 11 | # Ensure we only import stdlib modules 12 | # (This is convenient for users who want to use `amends https://raw.githubusercontent.com/jdx/hk/refs/tags/v1.27.0/pkl/Config.pkl`) 13 | @test "Config.pkl only imports stdlib" { 14 | # Parse the imports and check that all direct imports from Config.pkl start with "pkl:" 15 | run bash -c "pkl analyze imports --format json \"$PKL_PATH/Config.pkl\" | jq -r '.imports[\"file://$PKL_PATH/Config.pkl\"][] | .uri'" 16 | assert_success 17 | 18 | # Verify each import line starts with "pkl:" (stdlib) 19 | while IFS= read -r import_uri; do 20 | [[ "$import_uri" == pkl:* ]] || { 21 | echo "Non-stdlib import found: $import_uri" 22 | return 1 23 | } 24 | done <<< "$output" 25 | 26 | # Ensure we actually found at least one import 27 | assert_output --partial "pkl:" 28 | } 29 | -------------------------------------------------------------------------------- /test/pre_push.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | TEST_REPO_DIR="$(temp_make)" 7 | pushd "$TEST_REPO_DIR" 8 | git init --bare 9 | popd 10 | git remote add origin "$TEST_REPO_DIR" 11 | } 12 | teardown() { 13 | _common_teardown 14 | chmod -R u+w "$TEST_REPO_DIR" 15 | temp_del "$TEST_REPO_DIR" 16 | } 17 | 18 | @test "pre-push hook" { 19 | export NO_COLOR=1 20 | if [ "$HK_LIBGIT2" = "0" ]; then 21 | skip "libgit2 is not installed" 22 | fi 23 | cat < hk.pkl 24 | amends "$PKL_PATH/Config.pkl" 25 | import "$PKL_PATH/Builtins.pkl" 26 | hooks { ["pre-push"] { steps { ["prettier"] = Builtins.prettier } } } 27 | EOF 28 | git add hk.pkl 29 | git commit -m "install hk" 30 | git push origin main 31 | hk install 32 | echo 'console.log("test")' > test.js 33 | git add test.js 34 | git commit -m "test" 35 | HK_LOG=trace run git push origin main 36 | assert_failure 37 | assert_output --partial "[warn] test.js" 38 | } 39 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 4 | flake-utils.url = "github:numtide/flake-utils"; 5 | }; 6 | 7 | outputs = { self, nixpkgs, flake-utils }: 8 | { 9 | overlay = final: prev: { 10 | hk = prev.callPackage ./default.nix { }; 11 | }; 12 | } // flake-utils.lib.eachDefaultSystem(system: 13 | let 14 | pkgs = import nixpkgs { inherit system; }; 15 | hk = pkgs.callPackage ./default.nix { }; 16 | in 17 | { 18 | packages = { 19 | inherit hk; 20 | default = hk; 21 | }; 22 | 23 | devShells.default = pkgs.mkShell { 24 | name = "hk-develop"; 25 | 26 | inputsFrom = [ hk ]; 27 | 28 | nativeBuildInputs = with pkgs; [ 29 | just 30 | clippy 31 | rustfmt 32 | shfmt 33 | nodejs 34 | cargo-release 35 | cargo-insta 36 | ]; 37 | }; 38 | } 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /test/depends_condition_false.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | 8 | teardown() { 9 | _common_teardown 10 | } 11 | 12 | @test "dependent step proceeds when dependency's condition is false" { 13 | cat < hk.pkl 14 | amends "$PKL_PATH/Config.pkl" 15 | // Need to explicitly enable condition-false skip messages since default is only profile-not-enabled 16 | display_skip_reasons = List("profile-not-enabled", "condition-false") 17 | hooks { 18 | ["check"] { 19 | steps { 20 | ["a"] { 21 | condition = "false" 22 | check = "echo 'A SHOULD NOT RUN'" 23 | } 24 | ["b"] { 25 | depends = List("a") 26 | check = "echo 'B RUNS'" 27 | } 28 | } 29 | } 30 | } 31 | EOF 32 | run hk check 33 | assert_success 34 | assert_output --partial "a – skipped: condition is false" 35 | assert_output --partial "B RUNS" 36 | refute_output --partial "A SHOULD NOT RUN" 37 | } 38 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | { pkgs, lib, stdenv, fetchFromGitHub, rustPlatform, coreutils, bash, direnv, openssl, git }: 2 | let 3 | cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml); 4 | in rustPlatform.buildRustPackage { 5 | pname = "hk"; 6 | inherit (cargoToml.package) version; 7 | 8 | src = lib.cleanSource ./.; 9 | 10 | cargoLock = { 11 | lockFile = ./Cargo.lock; 12 | }; 13 | 14 | nativeBuildInputs = with pkgs; [ pkg-config ]; 15 | buildInputs = with pkgs; [ 16 | libgit2 17 | openssl 18 | ]; 19 | 20 | checkPhase = '' 21 | RUST_BACKTRACE=full cargo test --all-features -- \ 22 | --skip cli::util::python_check_ast::tests::test_invalid_python \ 23 | --skip settings::tests::test_settings_builder_fluent_api \ 24 | --skip settings::tests::test_settings_from_config \ 25 | --skip settings::tests::test_settings_snapshot_caching 26 | ''; 27 | 28 | meta = with lib; { 29 | description = "git hooks and project lints"; 30 | homepage = "https://github.com/jdx/hk"; 31 | license = licenses.mit; 32 | mainProgram = "hk"; 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /test/dir.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | teardown() { 8 | _common_teardown 9 | } 10 | 11 | @test "dir" { 12 | cat < hk.pkl 13 | amends "$PKL_PATH/Config.pkl" 14 | import "$PKL_PATH/builtins/prettier.pkl" 15 | hooks { 16 | ["check"] { 17 | steps { 18 | ["prettier"] { 19 | dir = "ui" 20 | glob = List("**/*.html", "**/*.ts") 21 | check = "prettier --no-color --check {{files}}" 22 | } 23 | } 24 | } 25 | } 26 | EOF 27 | git add hk.pkl 28 | git commit -m "initial commit" 29 | mkdir -p ui/subdir 30 | echo "test" > ui/subdir/test.html 31 | echo "console.log('test')" > ui/test.ts 32 | echo "console.log('test')" > root.ts 33 | git add ui/subdir/test.html ui/test.ts root.ts 34 | run hk check -v 35 | assert_failure 36 | assert_output --partial '[warn] subdir/test.html' 37 | assert_output --partial '[warn] test.ts' 38 | assert_output --partial '[warn] Code style issues found in 2 files.' 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025-2025 Jeff Dickey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /pkl/builtins/detect_private_key.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | import "./test/helpers.pkl" 4 | 5 | @Builtins.meta { 6 | category = "Special Purpose" 7 | description = "Detect accidentally committed private keys" 8 | } 9 | detect_private_key = new Config.Step { 10 | glob = "**/*" 11 | check = "hk util detect-private-key {{files}}" 12 | tests { 13 | local const testMaker = new helpers.TestMaker {} 14 | ["check RSA private key"] = 15 | testMaker.checkFail( 16 | """ 17 | -----BEGIN RSA PRIVATE KEY----- 18 | MIIEpAIBAAKCAQEA... 19 | -----END RSA PRIVATE KEY----- 20 | """, 21 | 1, 22 | ) 23 | ["check OpenSSH private key"] = 24 | testMaker.checkFail( 25 | """ 26 | -----BEGIN OPENSSH PRIVATE KEY----- 27 | b3BlbnNzaC1rZXktdjEAAAAABG5vbmU... 28 | -----END OPENSSH PRIVATE KEY----- 29 | """, 30 | 1, 31 | ) 32 | ["check public key"] = 33 | testMaker.checkPass("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC... user@host") 34 | ["check clean file"] = testMaker.checkPass("Hello, world!") 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /pkl/builtins/check_executables_have_shebangs.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "Special Purpose" 6 | description = "Verify executable files have shebang lines" 7 | } 8 | check_executables_have_shebangs = new Config.Step { 9 | glob = "**/*" 10 | check = "hk util check-executables-have-shebangs {{files}}" 11 | tests { 12 | ["detects executable without shebang"] { 13 | run = "check" 14 | write { ["{{tmp}}/script.sh"] = "echo hello" } 15 | before = "chmod +x {{tmp}}/script.sh" 16 | files = List("{{tmp}}/script.sh") 17 | expect { code = 1 } 18 | } 19 | ["passes executable with shebang"] { 20 | run = "check" 21 | write { ["{{tmp}}/script.sh"] = "#!/bin/bash\necho hello" } 22 | before = "chmod +x {{tmp}}/script.sh" 23 | files = List("{{tmp}}/script.sh") 24 | expect { code = 0 } 25 | } 26 | ["passes non-executable file"] { 27 | run = "check" 28 | write { ["{{tmp}}/script.sh"] = "echo hello" } 29 | before = "chmod 644 {{tmp}}/script.sh" 30 | files = List("{{tmp}}/script.sh") 31 | expect { code = 0 } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /test/localconfig.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | 8 | teardown() { 9 | _common_teardown 10 | } 11 | 12 | @test "loads hk.local.pkl from project directory" { 13 | cat < hk.pkl 14 | amends "$PKL_PATH/Config.pkl" 15 | hooks { 16 | ["check"] { 17 | steps { 18 | ["echo"] { check = "env" } 19 | } 20 | } 21 | } 22 | EOF 23 | 24 | run hk run check 25 | assert_success 26 | refute_output --partial "STEP_VAR=step_value" 27 | refute_output --partial "hello" 28 | 29 | cat < hk.local.pkl 30 | amends "./hk.pkl" 31 | import "./hk.pkl" as repo_config 32 | 33 | hooks = (repo_config.hooks) { 34 | ["check"] { 35 | steps { 36 | ["echo"] { 37 | env { 38 | ["STEP_VAR"] = "step_value" 39 | } 40 | } 41 | ["new step"] { 42 | check = "echo 'hello'" 43 | } 44 | } 45 | } 46 | } 47 | EOF 48 | 49 | run hk run check 50 | assert_success 51 | assert_output --partial "STEP_VAR=step_value" 52 | assert_output --partial "hello" 53 | } 54 | -------------------------------------------------------------------------------- /pkl/builtins/python_debug_statements.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | import "./test/helpers.pkl" 4 | 5 | @Builtins.meta { 6 | category = "Python" 7 | description = "Detect debug statements in Python code" 8 | } 9 | python_debug_statements = new Config.Step { 10 | glob = "**/*.py" 11 | check = "hk util python-debug-statements {{files}}" 12 | tests { 13 | local const testMaker = new helpers.TestMaker { filename = "test.py" } 14 | ["check pdb"] = 15 | testMaker.checkFail( 16 | """ 17 | import pdb 18 | pdb.set_trace() 19 | """, 20 | 1, 21 | ) 22 | ["check breakpoint"] = 23 | testMaker.checkFail( 24 | """ 25 | def debug(): 26 | breakpoint() 27 | """, 28 | 1, 29 | ) 30 | ["check clean code"] = 31 | testMaker.checkPass( 32 | """ 33 | def hello(): 34 | print("Hello, world!") 35 | """, 36 | ) 37 | ["check commented debug"] = 38 | testMaker.checkPass( 39 | """ 40 | def hello(): 41 | # import pdb; pdb.set_trace() 42 | print("Hello") 43 | """, 44 | ) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /pkl/builtins/mise.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | import "./test/helpers.pkl" 4 | 5 | @Builtins.meta { 6 | category = "Infrastructure" 7 | description = "mise-en-place's built-in formatter and linter" 8 | } 9 | mise = new Config.Step { 10 | glob = 11 | List( 12 | "mise.toml", 13 | "mise.*.toml", 14 | ".mise.toml", 15 | ".mise.*.toml", 16 | "mise/config.toml", 17 | "mise/config.*.toml", 18 | ".mise/config.toml", 19 | ".mise/config.*.toml", 20 | ".config/mise.toml", 21 | ".config/mise.*.toml", 22 | ".config/mise/config.toml", 23 | ".config/mise/config.*.toml", 24 | ".config/mise/mise.toml", 25 | ".config/mise/mise.*.toml", 26 | ".config/mise/conf.d/*.toml", 27 | ) 28 | batch = true 29 | check = "mise fmt --check" 30 | fix = "mise fmt" 31 | tests { 32 | local const testMaker = new helpers.TestMaker { filename = "mise.toml" } 33 | ["check bad file"] = testMaker.checkFail("x = 1", 1) 34 | ["check good file"] = testMaker.checkPass("x = 1\n") 35 | ["fix bad file"] = testMaker.fixPass("x = 1", "x = 1\n") 36 | ["fix good file"] = testMaker.fixPass("x = 1\n", "x = 1\n") 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /pkl/builtins/ruff_format.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | import "./test/helpers.pkl" 4 | 5 | @Builtins.meta { 6 | category = "Python" 7 | description = "Fast Python formatter (part of ruff)" 8 | } 9 | ruff_format = new Config.Step { 10 | glob = List("**/*.py", "**/*.pyi") 11 | stage = "" 12 | check = "ruff format --force-exclude --check {{ files }}" 13 | check_diff = "ruff format --force-exclude --diff {{ files }}" 14 | fix = "ruff format --force-exclude {{ files }}" 15 | tests { 16 | local const testMaker = new helpers.TestMaker { 17 | filename = "test.py" 18 | extra_files = new Mapping { 19 | ["ruff.toml"] = "exclude = [\"excluded.py\"]\n" 20 | } 21 | } 22 | ["check bad file"] = testMaker.checkFail("x=1+2", 1) 23 | ["check good file"] = testMaker.checkPass("x = 1 + 2\n") 24 | ["fix bad file"] = testMaker.fixPass("x=1+2", "x = 1 + 2\n") 25 | ["fix good file"] = testMaker.fixPass("x = 1 + 2\n", "x = 1 + 2\n") 26 | ["check respects config exclude"] = testMaker.withFilename("excluded.py").checkPass("x=1+2") 27 | ["fix respects config exclude"] = 28 | testMaker.withFilename("excluded.py").fixPass("x=1+2", "x=1+2") 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/step_depends.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use crate::Result; 4 | use tokio::sync::watch; 5 | 6 | pub struct StepDepends { 7 | depends: HashMap, watch::Receiver)>, 8 | } 9 | 10 | impl StepDepends { 11 | pub fn new(names: &[&str]) -> Self { 12 | StepDepends { 13 | depends: names 14 | .iter() 15 | .map(|name| (name.to_string(), watch::channel(false))) 16 | .collect(), 17 | } 18 | } 19 | 20 | pub fn is_done(&self, step: &str) -> bool { 21 | let Some((_tx, rx)) = self.depends.get(step) else { 22 | return true; 23 | }; 24 | *rx.clone().borrow_and_update() 25 | } 26 | 27 | pub async fn wait_for(&self, step: &str) -> Result<()> { 28 | let Some((_tx, rx)) = self.depends.get(step) else { 29 | return Ok(()); 30 | }; 31 | let mut rx = rx.clone(); 32 | while !*rx.borrow_and_update() { 33 | rx.changed().await?; 34 | } 35 | Ok(()) 36 | } 37 | 38 | pub fn mark_done(&self, step: &str) -> Result<()> { 39 | let (tx, _rx) = self.depends.get(step).unwrap(); 40 | tx.send(true)?; 41 | Ok(()) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /pkl/builtins/ruff.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | import "./test/helpers.pkl" 4 | 5 | @Builtins.meta { 6 | category = "Python" 7 | description = "Fast Python linter" 8 | } 9 | ruff = new Config.Step { 10 | glob = List("**/*.py", "**/*.pyi") 11 | stage = "" 12 | check = "ruff check --force-exclude {{ files }}" 13 | check_diff = "ruff check --force-exclude --diff {{ files }}" 14 | fix = "ruff check --force-exclude --fix {{ files }}" 15 | tests { 16 | local const testMaker = new helpers.TestMaker { 17 | filename = "test.py" 18 | extra_files = new Mapping { 19 | ["ruff.toml"] = "exclude = [\"excluded.py\"]\n" 20 | } 21 | } 22 | local const before = "import sys\nprint('hello')\n" 23 | local const after = "print('hello')\n" 24 | ["check bad file"] = testMaker.checkFail(before, 1) 25 | ["check good file"] = testMaker.checkPass(after) 26 | ["fix bad file"] = testMaker.fixPass(before, after) 27 | ["fix good file"] = testMaker.fixPass(after, after) 28 | ["check respects config exclude"] = testMaker.withFilename("excluded.py").checkPass(before) 29 | ["fix respects config exclude"] = testMaker.withFilename("excluded.py").fixPass(before, before) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /pkl/builtins/pkl_format.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | import "./test/helpers.pkl" 4 | 5 | @Builtins.meta { 6 | category = "Configuration" 7 | description = "Pkl formatter" 8 | } 9 | pkl_format = new Config.Step { 10 | glob = "**/*.pkl" 11 | stage = "" 12 | check_list_files = "pkl format --diff-name-only {{ files }}" 13 | // NB: pkl format exits 11 even if it fixes files 14 | // (If you're using a version of pkl that includes https://github.com/apple/pkl/pull/1340 15 | // you can override this command to just use `pkl format --write {{ files }}` 16 | // and remove the `shell` definition) 17 | shell = "sh -c" 18 | fix = 19 | """ 20 | pkl format --write {{ files }} 21 | code=$? 22 | if [ $code -eq 11 ]; then 23 | exit 0 24 | else 25 | exit $code 26 | fi 27 | """ 28 | tests { 29 | local const testMaker = new helpers.TestMaker { filename = "test.pkl" } 30 | ["check bad file"] = testMaker.checkFail("x = new { }", 11) 31 | ["check good file"] = testMaker.checkPass("x = new {}\n") 32 | ["fix bad file"] = testMaker.fixPass("x = new { }", "x = new {}\n") 33 | ["fix good file"] = testMaker.fixPass("x = new {}\n", "x = new {}\n") 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /test/untracked_all.bats: -------------------------------------------------------------------------------- 1 | setup() { 2 | load 'test_helper/common_setup' 3 | _common_setup 4 | } 5 | teardown() { 6 | _common_teardown 7 | } 8 | 9 | @test "untracked changes are tested without --all" { 10 | cat < hk.pkl 11 | amends "$PKL_PATH/Config.pkl" 12 | hooks { 13 | ["check"] { 14 | steps { 15 | ["a"] { check = "echo files: {{files}}" } 16 | } 17 | } 18 | } 19 | EOF 20 | git init 21 | git add hk.pkl 22 | git commit -m "initial commit" 23 | mkdir -p src 24 | touch src/foo.rs 25 | touch src/bar.rs 26 | touch root.rs 27 | run hk check 28 | assert_success 29 | assert_output --partial "files: root.rs src/bar.rs src/foo.rs" 30 | } 31 | 32 | @test "untracked changes are tested with --all" { 33 | cat < hk.pkl 34 | amends "$PKL_PATH/Config.pkl" 35 | hooks { 36 | ["check"] { 37 | steps { 38 | ["a"] { check = "echo files: {{files}}" } 39 | } 40 | } 41 | } 42 | EOF 43 | git init 44 | git add hk.pkl 45 | git commit -m "initial commit" 46 | mkdir -p src 47 | touch src/foo.rs 48 | touch src/bar.rs 49 | touch root.rs 50 | run hk check --all 51 | assert_success 52 | assert_output --partial "files: hk.pkl root.rs src/bar.rs src/foo.rs" 53 | } 54 | -------------------------------------------------------------------------------- /test/hook_fix_default.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | 8 | teardown() { 9 | _common_teardown 10 | } 11 | 12 | @test "pre-commit defaults to check" { 13 | cat < hk.pkl 14 | amends "$PKL_PATH/Config.pkl" 15 | import "$PKL_PATH/Builtins.pkl" 16 | hooks { 17 | ["pre-commit"] { 18 | steps { 19 | ["trailing-whitespace"] = Builtins.trailing_whitespace 20 | } 21 | } 22 | } 23 | EOF 24 | git add hk.pkl 25 | git commit -m "init" 26 | echo "content " > file.txt 27 | git add file.txt 28 | 29 | run hk run pre-commit 30 | assert_failure 31 | 32 | run cat -e file.txt 33 | assert_success 34 | assert_output "content $" 35 | } 36 | 37 | @test "fix defaults to fix" { 38 | cat < hk.pkl 39 | amends "$PKL_PATH/Config.pkl" 40 | import "$PKL_PATH/Builtins.pkl" 41 | hooks { 42 | ["fix"] { 43 | steps { 44 | ["trailing-whitespace"] = Builtins.trailing_whitespace 45 | } 46 | } 47 | } 48 | EOF 49 | git add hk.pkl 50 | git commit -m "init" 51 | echo "content " > file.txt 52 | git add file.txt 53 | 54 | run hk run fix 55 | assert_success 56 | 57 | run cat -e file.txt 58 | assert_success 59 | assert_output "content$" 60 | } 61 | -------------------------------------------------------------------------------- /test/check_first_waits.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | 8 | teardown() { 9 | _common_teardown 10 | } 11 | 12 | @test "check_first waits" { 13 | cat < hk.pkl 14 | amends "$PKL_PATH/Config.pkl" 15 | import "$PKL_PATH/Builtins.pkl" 16 | hooks { 17 | ["pre-commit"] { 18 | stash = "git" 19 | fix = true 20 | steps { 21 | ["a"] { 22 | glob = List("*.sh") 23 | stage = "*" 24 | check_first = true 25 | check = "echo 'start a' && sleep 0.1 && echo 'exit a' && exit 1" 26 | fix = "echo 'start a' && sleep 0.1 && echo 'end a'" 27 | } 28 | ["b"] { 29 | glob = List("*.sh") 30 | stage = "*" 31 | check_first = true 32 | check = "echo 'start b' && echo 'exit b' && exit 1" 33 | fix = "echo 'start b' && echo 'end b' > test.sh && echo 'end b'" 34 | } 35 | } 36 | } 37 | } 38 | EOF 39 | git add hk.pkl 40 | git commit -m "init" 41 | touch test.sh 42 | git add test.sh 43 | run hk run pre-commit 44 | assert_success 45 | 46 | # runs b to completion without a 47 | assert_output --partial " b – start b 48 | b – end b" 49 | } 50 | -------------------------------------------------------------------------------- /pkl/builtins/prettier.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | 4 | @Builtins.meta { 5 | category = "JavaScript/TypeScript" 6 | description = "Opinionated code formatter" 7 | } 8 | prettier = new Config.Step { 9 | glob = 10 | List( 11 | "**/*.js", 12 | "**/*.jsx", 13 | "**/*.mjs", 14 | "**/*.cjs", 15 | "**/*.ts", 16 | "**/*.tsx", 17 | "**/*.mts", 18 | "**/*.cts", 19 | "**/*.css", 20 | "**/*.scss", 21 | "**/*.less", 22 | "**/*.html", 23 | "**/*.json", 24 | "**/*.json5", 25 | "**/*.jsonc", 26 | "**/*.yaml", 27 | "**/*.yml", 28 | "**/*.markdown", 29 | "**/*.markdown.mdx", 30 | "**/*.md", 31 | "**/*.graphql", 32 | "**/*.handlebars", 33 | "**/*.svelte", 34 | "**/*.astro", 35 | "**/*.htmlangular", 36 | "**/*.vue", 37 | ) 38 | stage = "" 39 | batch = true 40 | check = "prettier --check {{ files }}" 41 | check_list_files = "prettier --list-different {{ files }}" 42 | fix = "prettier --write {{ files }}" 43 | tests { 44 | ["formats json"] { 45 | run = "fix" 46 | write { ["{{tmp}}/a.json"] = #"{"b":1}"# } 47 | files = List("{{tmp}}/a.json") 48 | expect { 49 | files { ["{{tmp}}/a.json"] = #"{ "b": 1 }\#n"# } 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/cli/run/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::Result; 2 | use crate::hook_options::HookOptions; 3 | 4 | mod commit_msg; 5 | mod pre_commit; 6 | mod pre_push; 7 | mod prepare_commit_msg; 8 | 9 | /// Run a hook 10 | #[derive(clap::Args)] 11 | #[clap( 12 | arg_required_else_help = true, 13 | visible_alias = "r", 14 | verbatim_doc_comment 15 | )] 16 | pub struct Run { 17 | #[clap(subcommand)] 18 | command: Option, 19 | #[clap(hide = true)] 20 | other: Option, 21 | #[clap(flatten)] 22 | hook: HookOptions, 23 | } 24 | 25 | #[derive(clap::Subcommand)] 26 | enum Commands { 27 | CommitMsg(commit_msg::CommitMsg), 28 | PreCommit(pre_commit::PreCommit), 29 | PrePush(pre_push::PrePush), 30 | PrepareCommitMsg(prepare_commit_msg::PrepareCommitMsg), 31 | } 32 | 33 | impl Run { 34 | pub async fn run(self) -> Result<()> { 35 | if let Some(hook) = &self.other { 36 | return self.hook.run(hook).await; 37 | } 38 | if let Some(cmd) = self.command { 39 | return match cmd { 40 | Commands::CommitMsg(cmd) => cmd.run().await, 41 | Commands::PreCommit(cmd) => cmd.run().await, 42 | Commands::PrePush(cmd) => cmd.run().await, 43 | Commands::PrepareCommitMsg(cmd) => cmd.run().await, 44 | }; 45 | } 46 | Ok(()) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /pkl/UserConfig.pkl: -------------------------------------------------------------------------------- 1 | @ModuleInfo { minPklVersion = "0.27.2" } 2 | module hk.UserConfig 3 | 4 | import "Config.pkl" 5 | 6 | class Defaults { 7 | jobs: UInt16? 8 | fail_fast: Boolean? 9 | profiles: List? 10 | all: Boolean? 11 | fix: Boolean? 12 | check: Boolean? 13 | exclude: (String | List)? 14 | skip_steps: (String | List)? 15 | skip_hooks: (String | List)? 16 | } 17 | 18 | class HookConfig { 19 | /// Overrides 20 | environment: Mapping = new Mapping {} 21 | jobs: UInt16? 22 | fail_fast: Boolean? 23 | profiles: List? 24 | all: Boolean? 25 | fix: Boolean? 26 | check: Boolean? 27 | 28 | /// Per-step overrides within this hook 29 | steps: Mapping = new Mapping {} 30 | } 31 | 32 | class StepConfig { 33 | environment: Mapping = new Mapping {} 34 | profiles: List? 35 | glob: (String | List)? 36 | exclude: (String | List)? 37 | } 38 | 39 | environment: Mapping = new Mapping {} 40 | defaults: Defaults = new Defaults {} 41 | hooks: Mapping = new Mapping {} 42 | 43 | stage: Boolean? 44 | // Optional top-level configuration 45 | display_skip_reasons: List? 46 | hide_warnings: List? 47 | warnings: List? 48 | -------------------------------------------------------------------------------- /docs/public/javascript-project.pkl: -------------------------------------------------------------------------------- 1 | /// Example configuration for a JavaScript/TypeScript project 2 | /// * Uses prettier for formatting 3 | /// * Uses eslint for linting 4 | /// * Runs type checking with tsc 5 | /// * Enables automatic fixes in pre-commit 6 | amends "package://github.com/jdx/hk/releases/download/v1.28.0/hk@1.28.0#/Config.pkl" 7 | import "package://github.com/jdx/hk/releases/download/v1.28.0/hk@1.28.0#/Builtins.pkl" 8 | 9 | // Configure environment for all tools 10 | env { 11 | ["NODE_ENV"] = "development" 12 | } 13 | 14 | // Define linters to use across hooks 15 | local linters = new Mapping { 16 | ["prettier"] = (Builtins.prettier) { 17 | // Enable batch processing for performance 18 | batch = true 19 | // Run prettier after other formatters 20 | depends = List("eslint") 21 | } 22 | ["eslint"] = (Builtins.eslint) { 23 | batch = true 24 | } 25 | ["tsc"] = (Builtins.tsc) { 26 | // Type checking doesn't need file locking 27 | stomp = true 28 | } 29 | } 30 | 31 | hooks { 32 | ["pre-commit"] { 33 | // Enable automatic fixes 34 | fix = true 35 | // Stash unstaged changes 36 | stash = "git" 37 | steps = linters 38 | } 39 | ["pre-push"] { 40 | // Just check, don't fix 41 | steps = linters 42 | } 43 | ["check"] { 44 | steps = linters 45 | } 46 | ["fix"] { 47 | fix = true 48 | steps = linters 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /test/hk_test_sandboxing.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | 8 | teardown() { 9 | _common_teardown 10 | } 11 | 12 | @test "hk test defaults to project root when {{tmp}} not used" { 13 | cat < hk.pkl 14 | amends "$PKL_PATH/Config.pkl" 15 | hooks { 16 | ["check"] { 17 | steps { 18 | ["cwd"] { 19 | check = #"echo "pwd: \$(pwd)""# 20 | tests { 21 | ["prints working directory"] { 22 | run = "check" 23 | // Expect project root 24 | expect { stdout = "pwd: $(pwd)" } 25 | } 26 | } 27 | } 28 | } 29 | } 30 | } 31 | PKL 32 | 33 | run hk test --step cwd 34 | assert_success 35 | } 36 | 37 | @test "hk test ignores step.dir during tests (not sandboxed)" { 38 | mkdir -p app 39 | cat < hk.pkl 40 | amends "$PKL_PATH/Config.pkl" 41 | hooks { 42 | ["check"] { 43 | steps { 44 | ["cwd_dir"] { 45 | dir = "app" 46 | check = #"echo "pwd: \$(pwd)""# 47 | tests { 48 | ["prints working directory under dir"] { 49 | run = "check" 50 | // step.dir is ignored during tests, so still expect project root 51 | expect { stdout = "pwd: $(pwd)" } 52 | } 53 | } 54 | } 55 | } 56 | } 57 | } 58 | PKL 59 | 60 | run hk test --step cwd_dir 61 | assert_success 62 | } 63 | -------------------------------------------------------------------------------- /pkl/builtins/stylelint.pkl: -------------------------------------------------------------------------------- 1 | import "../Builtins.pkl" 2 | import "../Config.pkl" 3 | import "./test/helpers.pkl" 4 | 5 | @Builtins.meta { 6 | category = "CSS" 7 | description = "CSS linter" 8 | } 9 | stylelint = new Config.Step { 10 | glob = List("**/*.css", "**/*.scss", "**/*.sass", "**/*.less") 11 | stage = "" 12 | check = "stylelint {{ files }}" 13 | fix = "stylelint --fix {{ files }}" 14 | tests { 15 | local const testMaker = new helpers.TestMaker { 16 | filename = "test.css" 17 | extra_files = new Mapping { 18 | ["stylelint.config.mjs"] = 19 | """ 20 | /** @type {import('stylelint').Config} */ 21 | export default { 22 | "rules": { 23 | "declaration-property-value-keyword-no-deprecated": true, 24 | "media-type-no-deprecated": true, 25 | } 26 | }; 27 | """ 28 | } 29 | } 30 | ["check bad file"] = testMaker.checkFail("a { overflow: overlay; }\n", 2) 31 | ["check good file"] = testMaker.checkPass("a { overflow: auto; }\n") 32 | ["fix bad file"] = testMaker.fixPass("a { overflow: overlay; }\n", "a { overflow: auto; }\n") 33 | ["fix good file"] = testMaker.fixPass("a { overflow: auto; }\n", "a { overflow: auto; }\n") 34 | ["fix semi-fixable"] = 35 | testMaker.fixFail( 36 | "a { overflow: overlay; }\n\n@media tty {}\n", 37 | "a { overflow: auto; }\n\n@media tty {}\n", 38 | 2, 39 | ) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /settings-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json-schema.org/draft-07/schema#", 3 | "title": "HK Settings Registry", 4 | "description": "Simplified schema for settings.toml (internal registry)", 5 | "type": "object", 6 | "additionalProperties": false, 7 | "patternProperties": { 8 | "^[a-z][a-z0-9_]*$": { 9 | "type": "object", 10 | "additionalProperties": false, 11 | "required": ["type", "docs"], 12 | "properties": { 13 | "type": { "type": "string" }, 14 | "default": {}, 15 | "merge": { "type": "string", "enum": ["union", "replace"] }, 16 | "sources": { 17 | "type": "object", 18 | "additionalProperties": false, 19 | "properties": { 20 | "cli": { "type": "array", "items": { "type": "string" } }, 21 | "env": { "type": "array", "items": { "type": "string" } }, 22 | "git": { "type": "array", "items": { "type": "string" } }, 23 | "pkl": { "type": "array", "items": { "type": "string" } } 24 | } 25 | }, 26 | "validate": { 27 | "type": "object", 28 | "additionalProperties": false, 29 | "properties": { 30 | "enum": { "type": "array", "items": { "type": "string" } } 31 | } 32 | }, 33 | "docs": { "type": "string" }, 34 | "examples": { "type": "array", "items": { "type": "string" } }, 35 | "deprecated": { "type": "string" }, 36 | "since": { "type": "string" } 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/tests/config_unification.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | mod tests { 3 | use super::*; 4 | use crate::settings::Settings; 5 | use indexmap::IndexSet; 6 | 7 | #[test] 8 | fn test_exclude_union() { 9 | // Test that exclude patterns are properly unioned from multiple sources 10 | Settings::add_exclude(vec!["node_modules".to_string()]); 11 | Settings::add_exclude(vec!["target".to_string()]); 12 | 13 | let settings = Settings::get(); 14 | assert!(settings.exclude().contains("node_modules")); 15 | assert!(settings.exclude().contains("target")); 16 | } 17 | 18 | #[test] 19 | fn test_exclude_glob_patterns() { 20 | // Test that glob patterns work 21 | Settings::add_exclude(vec!["**/*.min.js".to_string()]); 22 | Settings::add_exclude(vec!["**/*.map".to_string()]); 23 | 24 | let settings = Settings::get(); 25 | assert!(settings.exclude().contains("**/*.min.js")); 26 | assert!(settings.exclude().contains("**/*.map")); 27 | } 28 | 29 | #[test] 30 | fn test_exclude_no_duplicates() { 31 | // Test that duplicates are not added 32 | Settings::add_exclude(vec!["dist".to_string()]); 33 | Settings::add_exclude(vec!["dist".to_string()]); // Add same pattern twice 34 | 35 | let settings = Settings::get(); 36 | let count = settings.exclude().iter() 37 | .filter(|p| *p == "dist") 38 | .count(); 39 | assert_eq!(count, 1, "Should not have duplicate patterns"); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /test/stage_generated_files.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | 8 | teardown() { 9 | _common_teardown 10 | } 11 | 12 | @test "fix step stages generated files outside glob" { 13 | cat < hk.pkl 14 | amends "$PKL_PATH/Config.pkl" 15 | hooks { 16 | ["fix"] { 17 | fix = true 18 | steps { 19 | ["fooment-protos"] { 20 | glob = List("config/logging/schema_foomake.ts") 21 | stage = List("fooment/schemas/**", "config/logging/generated/frontend_schema.ts") 22 | fix = "mkdir -p fooment/schemas config/logging/generated && echo generated > fooment/schemas/generated.proto && echo schema > config/logging/generated/frontend_schema.ts" 23 | } 24 | } 25 | } 26 | } 27 | EOF 28 | git add hk.pkl 29 | git -c commit.gpgsign=false commit -m "init hk" 30 | 31 | mkdir -p config/logging 32 | cat <<'TS' > config/logging/schema_foomake.ts 33 | export const schema = 'initial' 34 | TS 35 | git add config/logging/schema_foomake.ts 36 | git -c commit.gpgsign=false commit -m "add schema" 37 | 38 | cat <<'TS' > config/logging/schema_foomake.ts 39 | export const schema = 'changed' 40 | TS 41 | 42 | run hk fix -v 43 | assert_success 44 | 45 | run git status --porcelain -- fooment/schemas/generated.proto config/logging/generated/frontend_schema.ts 46 | assert_success 47 | assert_line --regexp '^A fooment/schemas/generated\.proto$' 48 | assert_line --regexp '^A config/logging/generated/frontend_schema\.ts$' 49 | } 50 | -------------------------------------------------------------------------------- /scripts/reflect.pkl: -------------------------------------------------------------------------------- 1 | /// A PKL script to help output a reflected module. 2 | /// Example Invocation: pkl eval --format json -x 'import("file://scripts/reflect.pkl").render(module)' 3 | module hk.reflect 4 | import "pkl:reflect" 5 | 6 | local renderer = module.output.renderer 7 | function render(mod: Module): String = renderer.renderValue(reflect.Module(mod)) 8 | 9 | // NB: We reuse module output renderer, so we get the `--format` switching for free. 10 | output { 11 | renderer { 12 | converters { 13 | [reflect.Class] = (c) -> new Dynamic { 14 | name = c.name 15 | properties = c.properties 16 | } 17 | [reflect.Property] = (p) -> new Dynamic { 18 | name = p.name 19 | type = p.type 20 | docComment = p.docComment 21 | defaultValue = if (p.name == "output") null else renderer.renderValue(p.defaultValue) 22 | annnotations = p.annotations 23 | } 24 | [reflect.StringLiteralType] = (s) -> s.value 25 | [reflect.NullableType] = (t) -> "\(outer[t.member.getClass()].apply(t.member))?" 26 | [reflect.DeclaredType] = (d) -> 27 | if (d.typeArguments.isEmpty) 28 | d.referent.name 29 | else 30 | "\(d.referent.name)<\(d.typeArguments.map((arg) -> outer[arg.getClass()].apply(arg)).join(", "))>" 31 | [reflect.UnknownType] = (u) -> "Unknown" 32 | [reflect.UnionType] = (u) -> 33 | u.members.map((member) -> outer[member.getClass()].apply(member)).join(" | ") 34 | [reflect.Type] = (t) -> t.name 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /test/fail_fast_config.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | 8 | teardown() { 9 | _common_teardown 10 | } 11 | 12 | @test "fail_fast=true aborts on first failure" { 13 | cat < hk.pkl 14 | amends "$PKL_PATH/Config.pkl" 15 | fail_fast = true 16 | hooks { 17 | ["check"] { 18 | steps { 19 | ["first"] { 20 | exclusive = true 21 | check = "sh -c 'echo FIRST && exit 2'" 22 | } 23 | ["second"] { check = "echo SECOND" } 24 | } 25 | } 26 | } 27 | EOF 28 | git add hk.pkl 29 | git commit -m "init" 30 | echo "test" > test.txt 31 | 32 | run hk check 33 | assert_failure 34 | assert_output --partial "FIRST" 35 | # Should not run the second step when fail_fast=true 36 | refute_output --partial "SECOND" 37 | } 38 | 39 | @test "fail_fast=false continues after failure" { 40 | cat < hk.pkl 41 | amends "$PKL_PATH/Config.pkl" 42 | fail_fast = false 43 | hooks { 44 | ["check"] { 45 | steps { 46 | ["first"] { 47 | exclusive = true 48 | check = "sh -c 'echo FIRST && exit 2'" 49 | } 50 | ["second"] { check = "echo SECOND" } 51 | } 52 | } 53 | } 54 | EOF 55 | git add hk.pkl 56 | git commit -m "init" 57 | echo "test" > test.txt 58 | 59 | run hk check 60 | # Overall run still fails due to first step 61 | assert_failure 62 | assert_output --partial "FIRST" 63 | # With fail_fast=false, the second step should still run 64 | assert_output --partial "SECOND" 65 | } 66 | -------------------------------------------------------------------------------- /test/stash_default.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | 8 | teardown() { 9 | _common_teardown 10 | } 11 | 12 | @test "stash default for pre-commit" { 13 | cat < hk.pkl 14 | amends "$PKL_PATH/Config.pkl" 15 | import "$PKL_PATH/Builtins.pkl" 16 | hooks { 17 | ["pre-commit"] { 18 | fix = true 19 | steps { 20 | ["trailing-whitespace"] = Builtins.trailing_whitespace 21 | } 22 | } 23 | } 24 | PKL 25 | git add hk.pkl 26 | git commit -m "Required initial commit" 27 | echo "content " > file.txt 28 | git add file.txt 29 | echo "changed content " > file.txt 30 | 31 | run hk run pre-commit 32 | assert_success 33 | refute_output --partial "Stashed unstaged changes" 34 | run cat -e file.txt 35 | assert_success 36 | assert_output "changed content$" 37 | } 38 | 39 | @test "stash default for fix" { 40 | cat < hk.pkl 41 | amends "$PKL_PATH/Config.pkl" 42 | import "$PKL_PATH/Builtins.pkl" 43 | hooks { 44 | ["fix"] { 45 | fix = true 46 | steps { 47 | ["trailing-whitespace"] = Builtins.trailing_whitespace 48 | } 49 | } 50 | } 51 | PKL 52 | git add hk.pkl 53 | git commit -m "Required initial commit" 54 | echo "content " > file.txt 55 | git add file.txt 56 | echo "changed content " > file.txt 57 | 58 | run hk run fix 59 | refute_output --partial "Stashed unstaged changes" 60 | run cat -e file.txt 61 | assert_success 62 | assert_output "changed content$" 63 | } 64 | -------------------------------------------------------------------------------- /test/test_helper/cache_setup.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Helper functions for managing cache in tests 4 | # 5 | # The test framework enables caching by setting HK_CACHE=1 6 | # This overrides the default behavior (cache disabled in debug builds) 7 | 8 | # Enable cache for tests 9 | _enable_test_cache() { 10 | # Enable caching (overrides debug build default) 11 | export HK_CACHE=1 12 | 13 | # Use a persistent cache directory in the system temp folder 14 | # This survives between test runs for performance 15 | export HK_CACHE_DIR="${BATS_TEST_TMPDIR:-/tmp}/hk-test-cache" 16 | 17 | # Create the cache directory if it doesn't exist 18 | mkdir -p "$HK_CACHE_DIR" 19 | 20 | # Clear stale cache entries (older than 1 day) 21 | if command -v find >/dev/null 2>&1; then 22 | find "$HK_CACHE_DIR" -type f -mtime +1 -delete 2>/dev/null || true 23 | fi 24 | } 25 | 26 | # Disable cache for specific tests 27 | _disable_test_cache() { 28 | export HK_CACHE=0 29 | } 30 | 31 | # Clear the test cache 32 | _clear_test_cache() { 33 | if [ -n "$HK_CACHE_DIR" ] && [ -d "$HK_CACHE_DIR" ]; then 34 | rm -rf "$HK_CACHE_DIR"/* 35 | fi 36 | } 37 | 38 | # Get cache statistics (useful for debugging) 39 | _test_cache_stats() { 40 | if [ -n "$HK_CACHE_DIR" ] && [ -d "$HK_CACHE_DIR" ]; then 41 | local count=$(find "$HK_CACHE_DIR" -type f -name "*.json" 2>/dev/null | wc -l) 42 | local size=$(du -sh "$HK_CACHE_DIR" 2>/dev/null | cut -f1) 43 | echo "Cache: $count files, $size in $HK_CACHE_DIR" 44 | else 45 | echo "Cache: disabled or empty" 46 | fi 47 | } 48 | -------------------------------------------------------------------------------- /test/workspace_indicator.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | _workspace_indicator_setup 7 | } 8 | 9 | teardown() { 10 | _common_teardown 11 | } 12 | 13 | _workspace_indicator_setup() { 14 | # Use current directory for all setup 15 | mkdir -p a b 16 | 17 | touch go.mod main.go 18 | touch a/go.mod a/main.go 19 | touch b/go.mod b/main.go 20 | 21 | cat > hk.pkl < { 25 | ["golangci-lint"] { 26 | glob = "*.go" 27 | workspace_indicator = "go.mod" 28 | check = "echo \"ws={{workspace}}; files={{files}}; wfiles={{workspace_files}}\"" 29 | } 30 | } 31 | 32 | hooks { 33 | ["check"] { 34 | steps = linters 35 | } 36 | } 37 | EOF 38 | 39 | git init -q 40 | git add . 41 | } 42 | 43 | @test "each workspace only processes its own files" { 44 | run hk check -v 45 | 46 | # Should see three jobs, one for each workspace, each with only its own file 47 | # Root workspace 48 | assert_output --partial "echo \"ws=.; files=main.go; wfiles=main.go\"" 49 | # Workspace a 50 | assert_output --partial "echo \"ws=a; files=a/main.go; wfiles=main.go\"" 51 | # Workspace b 52 | assert_output --partial "echo \"ws=b; files=b/main.go; wfiles=main.go\"" 53 | 54 | # Should NOT see a/main.go or b/main.go in the root workspace's echo 55 | # (i.e., no echo with multiple files) 56 | refute_output --partial "files=a/main.go b/main.go main.go" 57 | refute_output --partial "files=a/main.go main.go" 58 | refute_output --partial "files=b/main.go main.go" 59 | } 60 | -------------------------------------------------------------------------------- /docs/cli/util.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk util` 4 | 5 | - **Usage**: `hk util ` 6 | 7 | Utility commands for file operations 8 | 9 | ## Subcommands 10 | 11 | - [`hk util check-added-large-files [--maxkb ] …`](/cli/util/check-added-large-files.md) 12 | - [`hk util check-byte-order-marker …`](/cli/util/check-byte-order-marker.md) 13 | - [`hk util check-case-conflict …`](/cli/util/check-case-conflict.md) 14 | - [`hk util check-conventional-commit [--allowed-types… ] `](/cli/util/check-conventional-commit.md) 15 | - [`hk util check-executables-have-shebangs …`](/cli/util/check-executables-have-shebangs.md) 16 | - [`hk util check-merge-conflict [--assume-in-merge] …`](/cli/util/check-merge-conflict.md) 17 | - [`hk util check-symlinks …`](/cli/util/check-symlinks.md) 18 | - [`hk util detect-private-key …`](/cli/util/detect-private-key.md) 19 | - [`hk util end-of-file-fixer [-f --fix] …`](/cli/util/end-of-file-fixer.md) 20 | - [`hk util fix-byte-order-marker …`](/cli/util/fix-byte-order-marker.md) 21 | - [`hk util fix-smart-quotes …`](/cli/util/fix-smart-quotes.md) 22 | - [`hk util mixed-line-ending [-f --fix] …`](/cli/util/mixed-line-ending.md) 23 | - [`hk util no-commit-to-branch [--branch… ]`](/cli/util/no-commit-to-branch.md) 24 | - [`hk util python-check-ast …`](/cli/util/python-check-ast.md) 25 | - [`hk util python-debug-statements …`](/cli/util/python-debug-statements.md) 26 | - [`hk util trailing-whitespace [-f --fix] …`](/cli/util/trailing-whitespace.md) 27 | -------------------------------------------------------------------------------- /docs/cli/fix.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk fix` 4 | 5 | - **Usage**: `hk fix [FLAGS] [FILES]…` 6 | - **Aliases**: `f` 7 | 8 | Fixes code 9 | 10 | ## Arguments 11 | 12 | ### `[FILES]…` 13 | 14 | Run on specific files 15 | 16 | ## Flags 17 | 18 | ### `-a --all` 19 | 20 | Run on all files instead of just staged files 21 | 22 | ### `-c --check` 23 | 24 | Run check command instead of fix command 25 | 26 | ### `-e --exclude… ` 27 | 28 | Exclude files that otherwise would have been selected 29 | 30 | ### `-f --fix` 31 | 32 | Run fix command instead of check command (this is the default behavior unless HK_FIX=0) 33 | 34 | ### `-g --glob… ` 35 | 36 | Run on files that match these glob patterns 37 | 38 | ### `-P --plan` 39 | 40 | Print the plan instead of running the hook 41 | 42 | ### `-S --step… ` 43 | 44 | Run only specific step(s) 45 | 46 | ### `--fail-fast` 47 | 48 | Abort on first failure 49 | 50 | ### `--from-ref ` 51 | 52 | Start reference for checking files (requires --to-ref) 53 | 54 | ### `--no-fail-fast` 55 | 56 | Continue on failures (opposite of --fail-fast) 57 | 58 | ### `--no-stage` 59 | 60 | Disable auto-staging of fixed files 61 | 62 | ### `--skip-step… ` 63 | 64 | Skip specific step(s) 65 | 66 | ### `--stage` 67 | 68 | Enable auto-staging of fixed files 69 | 70 | ### `--stash ` 71 | 72 | Stash method to use for git hooks 73 | 74 | **Choices:** 75 | 76 | - `git` 77 | - `patch-file` 78 | - `none` 79 | 80 | ### `--stats` 81 | 82 | Display statistics about files matching each step 83 | 84 | ### `--to-ref ` 85 | 86 | End reference for checking files (requires --from-ref) 87 | -------------------------------------------------------------------------------- /docs/cli/check.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk check` 4 | 5 | - **Usage**: `hk check [FLAGS] [FILES]…` 6 | - **Aliases**: `c` 7 | 8 | Checks code 9 | 10 | ## Arguments 11 | 12 | ### `[FILES]…` 13 | 14 | Run on specific files 15 | 16 | ## Flags 17 | 18 | ### `-a --all` 19 | 20 | Run on all files instead of just staged files 21 | 22 | ### `-c --check` 23 | 24 | Run check command instead of fix command 25 | 26 | ### `-e --exclude… ` 27 | 28 | Exclude files that otherwise would have been selected 29 | 30 | ### `-f --fix` 31 | 32 | Run fix command instead of check command (this is the default behavior unless HK_FIX=0) 33 | 34 | ### `-g --glob… ` 35 | 36 | Run on files that match these glob patterns 37 | 38 | ### `-P --plan` 39 | 40 | Print the plan instead of running the hook 41 | 42 | ### `-S --step… ` 43 | 44 | Run only specific step(s) 45 | 46 | ### `--fail-fast` 47 | 48 | Abort on first failure 49 | 50 | ### `--from-ref ` 51 | 52 | Start reference for checking files (requires --to-ref) 53 | 54 | ### `--no-fail-fast` 55 | 56 | Continue on failures (opposite of --fail-fast) 57 | 58 | ### `--no-stage` 59 | 60 | Disable auto-staging of fixed files 61 | 62 | ### `--skip-step… ` 63 | 64 | Skip specific step(s) 65 | 66 | ### `--stage` 67 | 68 | Enable auto-staging of fixed files 69 | 70 | ### `--stash ` 71 | 72 | Stash method to use for git hooks 73 | 74 | **Choices:** 75 | 76 | - `git` 77 | - `patch-file` 78 | - `none` 79 | 80 | ### `--stats` 81 | 82 | Display statistics about files matching each step 83 | 84 | ### `--to-ref ` 85 | 86 | End reference for checking files (requires --from-ref) 87 | -------------------------------------------------------------------------------- /test/git_status_ad_deleted.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | 8 | teardown() { 9 | _common_teardown 10 | } 11 | 12 | @test "git status AD: staged-added but deleted in worktree should not be included in files list" { 13 | # Force shell git path to reproduce porcelain parsing behavior 14 | export HK_LIBGIT2=0 15 | export NO_COLOR=1 16 | 17 | mkdir -p ml/py 18 | 19 | cat < hk.pkl 20 | amends "$PKL_PATH/Config.pkl" 21 | hooks { 22 | ["pre-commit"] { 23 | stash = "git" 24 | steps { 25 | ["list"] { 26 | // capture the list of files hk passes to the step 27 | check = "printf '%s\n' {{files}} > files_list.txt" 28 | } 29 | } 30 | } 31 | } 32 | EOF 33 | git add hk.pkl 34 | git commit -m "init" 35 | 36 | # Create a staged file that exists so the hook runs 37 | echo "print('ok')" > ml/py/b.py 38 | git add ml/py/b.py 39 | 40 | # Create a file that is added to index, then remove it to produce AD 41 | echo "print('temp')" > ml/py/a.py 42 | git add ml/py/a.py 43 | rm ml/py/a.py 44 | 45 | # Sanity: ensure git shows AD for a.py 46 | run bash -lc "git status --porcelain --untracked-files=all | tr -d '\0'" 47 | assert_success 48 | assert_output --partial "AD ml/py/a.py" 49 | 50 | # Run pre-commit; 'list' step will write files_list.txt with the files hk selected 51 | run hk run pre-commit 52 | # Whether hk succeeds depends on tool config; we only care that it ran 53 | 54 | assert_file_exists files_list.txt 55 | # The deleted file should NOT be included in the files list 56 | assert_file_not_contains files_list.txt "ml/py/a.py" 57 | } 58 | -------------------------------------------------------------------------------- /docs/cli/run/pre-commit.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk run pre-commit` 4 | 5 | - **Usage**: `hk run pre-commit [FLAGS] [FILES]…` 6 | - **Aliases**: `pc` 7 | 8 | Sets up git hooks to run hk 9 | 10 | ## Arguments 11 | 12 | ### `[FILES]…` 13 | 14 | Run on specific files 15 | 16 | ## Flags 17 | 18 | ### `-a --all` 19 | 20 | Run on all files instead of just staged files 21 | 22 | ### `-c --check` 23 | 24 | Run check command instead of fix command 25 | 26 | ### `-e --exclude… ` 27 | 28 | Exclude files that otherwise would have been selected 29 | 30 | ### `-f --fix` 31 | 32 | Run fix command instead of check command (this is the default behavior unless HK_FIX=0) 33 | 34 | ### `-g --glob… ` 35 | 36 | Run on files that match these glob patterns 37 | 38 | ### `-P --plan` 39 | 40 | Print the plan instead of running the hook 41 | 42 | ### `-S --step… ` 43 | 44 | Run only specific step(s) 45 | 46 | ### `--fail-fast` 47 | 48 | Abort on first failure 49 | 50 | ### `--from-ref ` 51 | 52 | Start reference for checking files (requires --to-ref) 53 | 54 | ### `--no-fail-fast` 55 | 56 | Continue on failures (opposite of --fail-fast) 57 | 58 | ### `--no-stage` 59 | 60 | Disable auto-staging of fixed files 61 | 62 | ### `--skip-step… ` 63 | 64 | Skip specific step(s) 65 | 66 | ### `--stage` 67 | 68 | Enable auto-staging of fixed files 69 | 70 | ### `--stash ` 71 | 72 | Stash method to use for git hooks 73 | 74 | **Choices:** 75 | 76 | - `git` 77 | - `patch-file` 78 | - `none` 79 | 80 | ### `--stats` 81 | 82 | Display statistics about files matching each step 83 | 84 | ### `--to-ref ` 85 | 86 | End reference for checking files (requires --from-ref) 87 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1731533236, 9 | "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1759417375, 24 | "narHash": "sha256-O7eHcgkQXJNygY6AypkF9tFhsoDQjpNEojw3eFs73Ow=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "dc704e6102e76aad573f63b74c742cd96f8f1e6c", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "nixpkgs-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /docs/cli/run/pre-push.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk run pre-push` 4 | 5 | - **Usage**: `hk run pre-push [FLAGS] [ARGS]…` 6 | - **Aliases**: `pp` 7 | 8 | ## Arguments 9 | 10 | ### `[REMOTE]` 11 | 12 | Remote name 13 | 14 | ### `[URL]` 15 | 16 | Remote URL 17 | 18 | ### `[FILES]…` 19 | 20 | Run on specific files 21 | 22 | ## Flags 23 | 24 | ### `-a --all` 25 | 26 | Run on all files instead of just staged files 27 | 28 | ### `-c --check` 29 | 30 | Run check command instead of fix command 31 | 32 | ### `-e --exclude… ` 33 | 34 | Exclude files that otherwise would have been selected 35 | 36 | ### `-f --fix` 37 | 38 | Run fix command instead of check command (this is the default behavior unless HK_FIX=0) 39 | 40 | ### `-g --glob… ` 41 | 42 | Run on files that match these glob patterns 43 | 44 | ### `-P --plan` 45 | 46 | Print the plan instead of running the hook 47 | 48 | ### `-S --step… ` 49 | 50 | Run only specific step(s) 51 | 52 | ### `--fail-fast` 53 | 54 | Abort on first failure 55 | 56 | ### `--from-ref ` 57 | 58 | Start reference for checking files (requires --to-ref) 59 | 60 | ### `--no-fail-fast` 61 | 62 | Continue on failures (opposite of --fail-fast) 63 | 64 | ### `--no-stage` 65 | 66 | Disable auto-staging of fixed files 67 | 68 | ### `--skip-step… ` 69 | 70 | Skip specific step(s) 71 | 72 | ### `--stage` 73 | 74 | Enable auto-staging of fixed files 75 | 76 | ### `--stash ` 77 | 78 | Stash method to use for git hooks 79 | 80 | **Choices:** 81 | 82 | - `git` 83 | - `patch-file` 84 | - `none` 85 | 86 | ### `--stats` 87 | 88 | Display statistics about files matching each step 89 | 90 | ### `--to-ref ` 91 | 92 | End reference for checking files (requires --from-ref) 93 | -------------------------------------------------------------------------------- /scripts/generate-examples.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | # Script to extract and validate examples from docs/public/*.pkl files 5 | # These examples can be embedded in the documentation 6 | 7 | PUBLIC_DIR="docs/public" 8 | OUTPUT_DIR="docs/reference/examples" 9 | 10 | if [ ! -d "$PUBLIC_DIR" ]; then 11 | echo "No public examples directory found at $PUBLIC_DIR" 12 | exit 0 13 | fi 14 | 15 | echo "Extracting examples from $PUBLIC_DIR..." 16 | 17 | mkdir -p "$OUTPUT_DIR" 18 | 19 | # Process each .pkl file in the public directory 20 | for pkl_file in "$PUBLIC_DIR"/*.pkl; do 21 | if [ ! -f "$pkl_file" ]; then 22 | continue 23 | fi 24 | 25 | basename=$(basename "$pkl_file" .pkl) 26 | output_file="$OUTPUT_DIR/${basename}.md" 27 | 28 | echo "Processing $pkl_file -> $output_file" 29 | 30 | cat > "$output_file" << EOF 31 | # Example: ${basename} 32 | 33 | \`\`\`pkl 34 | $(cat "$pkl_file") 35 | \`\`\` 36 | 37 | ## Description 38 | 39 | $(grep -E "^///" "$pkl_file" 2>/dev/null | sed 's|^///[ ]*||' || echo "No description available.") 40 | 41 | ## Key Features 42 | 43 | $(grep -E "^//\s+\*" "$pkl_file" 2>/dev/null | sed 's|^//[ ]*||' || echo "- Standard configuration") 44 | 45 | EOF 46 | done 47 | 48 | # Generate index file 49 | cat > "$OUTPUT_DIR/index.md" << EOF 50 | # Configuration Examples 51 | 52 | This directory contains runnable examples extracted from the public Pkl configurations. 53 | 54 | ## Available Examples 55 | 56 | EOF 57 | 58 | for pkl_file in "$PUBLIC_DIR"/*.pkl; do 59 | if [ ! -f "$pkl_file" ]; then 60 | continue 61 | fi 62 | basename=$(basename "$pkl_file" .pkl) 63 | echo "- [${basename}](./${basename}.md)" >> "$OUTPUT_DIR/index.md" 64 | done 65 | 66 | echo "Examples generated in $OUTPUT_DIR" 67 | -------------------------------------------------------------------------------- /docs/reference/examples/javascript-project.md: -------------------------------------------------------------------------------- 1 | # Example: javascript-project 2 | 3 | ```pkl 4 | /// Example configuration for a JavaScript/TypeScript project 5 | /// * Uses prettier for formatting 6 | /// * Uses eslint for linting 7 | /// * Runs type checking with tsc 8 | /// * Enables automatic fixes in pre-commit 9 | 10 | amends "package://github.com/jdx/hk/releases/download/v1.28.0/hk@1.28.0#/Config.pkl" 11 | import "package://github.com/jdx/hk/releases/download/v1.28.0/hk@1.28.0#/Builtins.pkl" 12 | 13 | // Configure environment for all tools 14 | env { 15 | ["NODE_ENV"] = "development" 16 | } 17 | 18 | // Define linters to use across hooks 19 | local linters = new Mapping { 20 | ["prettier"] = (Builtins.prettier) { 21 | // Enable batch processing for performance 22 | batch = true 23 | // Run prettier after other formatters 24 | depends = List("eslint") 25 | } 26 | ["eslint"] = (Builtins.eslint) { 27 | batch = true 28 | } 29 | ["tsc"] = (Builtins.tsc) { 30 | // Type checking doesn't need file locking 31 | stomp = true 32 | } 33 | } 34 | 35 | hooks { 36 | ["pre-commit"] { 37 | // Enable automatic fixes 38 | fix = true 39 | // Stash unstaged changes 40 | stash = "git" 41 | steps = linters 42 | } 43 | ["pre-push"] { 44 | // Just check, don't fix 45 | steps = linters 46 | } 47 | ["check"] { 48 | steps = linters 49 | } 50 | ["fix"] { 51 | fix = true 52 | steps = linters 53 | } 54 | } 55 | ``` 56 | 57 | ## Description 58 | 59 | Example configuration for a JavaScript/TypeScript project 60 | * Uses prettier for formatting 61 | * Uses eslint for linting 62 | * Runs type checking with tsc 63 | * Enables automatic fixes in pre-commit 64 | 65 | ## Key Features 66 | 67 | - Standard configuration 68 | 69 | -------------------------------------------------------------------------------- /test/test_helper/common_setup.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | _common_setup() { 4 | load 'test_helper/bats-support/load' 5 | load 'test_helper/bats-assert/load' 6 | load 'test_helper/bats-file/load' 7 | load 'test_helper/cache_setup' 8 | 9 | export PROJECT_ROOT="$BATS_TEST_DIRNAME/.." 10 | export PKL_PATH="$PROJECT_ROOT/pkl" 11 | 12 | # Create a temporary directory for each test 13 | TEST_TEMP_DIR="$(temp_make)" 14 | mkdir -p "$TEST_TEMP_DIR/src/proj" 15 | cd "$TEST_TEMP_DIR/src/proj" 16 | 17 | # Initialize a git repository 18 | export GIT_CONFIG_NOSYSTEM=1 19 | export HK_JOBS=2 20 | export MISE_INSTALLS_DIR="${MISE_INSTALLS_DIR:-$HOME/.local/share/mise/installs}" 21 | export HOME="$TEST_TEMP_DIR" 22 | git config --global init.defaultBranch main 23 | 24 | # Only set user config if not already set (to avoid overriding existing config) 25 | if ! git config --global user.email >/dev/null 2>&1; then 26 | git config --global user.email "test@example.com" 27 | fi 28 | if ! git config --global user.name >/dev/null 2>&1; then 29 | git config --global user.name "Test User" 30 | fi 31 | 32 | git init . 33 | 34 | # Add hk to PATH (assuming it's installed) 35 | # Use CARGO_TARGET_DIR if set (e.g., by mise), otherwise use local target 36 | if [ -n "$CARGO_TARGET_DIR" ]; then 37 | PATH="$CARGO_TARGET_DIR/debug:$PATH" 38 | else 39 | PATH="$(dirname $BATS_TEST_DIRNAME)/target/debug:$PATH" 40 | fi 41 | 42 | # Enable test cache by default for better performance 43 | # Individual tests can override this by calling _disable_test_cache 44 | _enable_test_cache 45 | } 46 | 47 | _common_teardown() { 48 | chmod -R u+w "$TEST_TEMP_DIR" 49 | temp_del "$TEST_TEMP_DIR" 50 | } 51 | -------------------------------------------------------------------------------- /docs/cli/run/commit-msg.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk run commit-msg` 4 | 5 | - **Usage**: `hk run commit-msg [FLAGS] [FILES]…` 6 | - **Aliases**: `cm` 7 | 8 | ## Arguments 9 | 10 | ### `` 11 | 12 | The path to the file that contains the commit message 13 | 14 | ### `[FILES]…` 15 | 16 | Run on specific files 17 | 18 | ## Flags 19 | 20 | ### `-a --all` 21 | 22 | Run on all files instead of just staged files 23 | 24 | ### `-c --check` 25 | 26 | Run check command instead of fix command 27 | 28 | ### `-e --exclude… ` 29 | 30 | Exclude files that otherwise would have been selected 31 | 32 | ### `-f --fix` 33 | 34 | Run fix command instead of check command (this is the default behavior unless HK_FIX=0) 35 | 36 | ### `-g --glob… ` 37 | 38 | Run on files that match these glob patterns 39 | 40 | ### `-P --plan` 41 | 42 | Print the plan instead of running the hook 43 | 44 | ### `-S --step… ` 45 | 46 | Run only specific step(s) 47 | 48 | ### `--fail-fast` 49 | 50 | Abort on first failure 51 | 52 | ### `--from-ref ` 53 | 54 | Start reference for checking files (requires --to-ref) 55 | 56 | ### `--no-fail-fast` 57 | 58 | Continue on failures (opposite of --fail-fast) 59 | 60 | ### `--no-stage` 61 | 62 | Disable auto-staging of fixed files 63 | 64 | ### `--skip-step… ` 65 | 66 | Skip specific step(s) 67 | 68 | ### `--stage` 69 | 70 | Enable auto-staging of fixed files 71 | 72 | ### `--stash ` 73 | 74 | Stash method to use for git hooks 75 | 76 | **Choices:** 77 | 78 | - `git` 79 | - `patch-file` 80 | - `none` 81 | 82 | ### `--stats` 83 | 84 | Display statistics about files matching each step 85 | 86 | ### `--to-ref ` 87 | 88 | End reference for checking files (requires --from-ref) 89 | -------------------------------------------------------------------------------- /test/depends.bats: -------------------------------------------------------------------------------- 1 | setup() { 2 | load 'test_helper/common_setup' 3 | _common_setup 4 | } 5 | teardown() { 6 | _common_teardown 7 | } 8 | 9 | @test "depends" { 10 | cat < hk.pkl 11 | amends "$PKL_PATH/Config.pkl" 12 | import "$PKL_PATH/Builtins.pkl" 13 | hooks { 14 | ["fix"] { 15 | steps { 16 | ["a"] { fix = "echo ITWORKS > a.txt" } 17 | ["b"] { fix = "cat a.txt > b.txt"; depends = List("a") } 18 | ["c"] { fix = "cat b.txt > c.txt"; depends = List("b") } 19 | ["d"] { fix = "cat c.txt > d.txt"; depends = List("c") } 20 | ["e"] { depends = List("d") 21 | check = """ 22 | if [ \$(cat d.txt) = "ITWORKS" ]; then 23 | exit 0 24 | fi 25 | echo "d.txt does not contain ITWORKS" 26 | exit 1 27 | """ } 28 | } 29 | } 30 | } 31 | EOF 32 | git add hk.pkl 33 | git commit -m "initial commit" 34 | hk fix -v 35 | } 36 | 37 | @test "file depends" { 38 | cat < hk.pkl 39 | amends "$PKL_PATH/Config.pkl" 40 | import "$PKL_PATH/Builtins.pkl" 41 | hooks { 42 | ["fix"] { 43 | steps { 44 | ["a"] { fix = "echo ITWORKS > a.txt"; stage = "a.txt" } 45 | ["b"] { fix = "cat a.txt > b.txt"; depends = List("a"); stage = "b.txt"; glob = "a.txt" } 46 | ["c"] { fix = "cat b.txt > c.txt"; depends = List("b"); stage = "c.txt"; glob = "b.txt" } 47 | ["d"] { fix = "cat c.txt > d.txt"; depends = List("c"); stage = "d.txt"; glob = "c.txt" } 48 | ["e"] { depends = List("d") 49 | glob = "d.txt" 50 | check = """ 51 | if [ \$(cat d.txt) = "ITWORKS" ]; then 52 | exit 0 53 | fi 54 | echo "d.txt does not contain ITWORKS" 55 | exit 1 56 | """ } 57 | } 58 | } 59 | } 60 | EOF 61 | git add hk.pkl 62 | git commit -m "initial commit" 63 | hk fix -v 64 | } 65 | -------------------------------------------------------------------------------- /src/cli/util/no_commit_to_branch.rs: -------------------------------------------------------------------------------- 1 | use crate::Result; 2 | use std::process::Command; 3 | 4 | #[derive(Debug, clap::Args)] 5 | pub struct NoCommitToBranch { 6 | /// Branch names to protect (default: main, master) 7 | #[clap(long, value_delimiter = ',')] 8 | pub branch: Option>, 9 | } 10 | 11 | impl NoCommitToBranch { 12 | pub async fn run(&self) -> Result<()> { 13 | let protected_branches = self 14 | .branch 15 | .clone() 16 | .unwrap_or_else(|| vec!["main".to_string(), "master".to_string()]); 17 | 18 | let current_branch = get_current_branch()?; 19 | 20 | if protected_branches.contains(¤t_branch) { 21 | return Err(std::io::Error::other(format!( 22 | "Cannot commit directly to protected branch '{}'", 23 | current_branch 24 | )) 25 | .into()); 26 | } 27 | 28 | Ok(()) 29 | } 30 | } 31 | 32 | fn get_current_branch() -> Result { 33 | // Use symbolic-ref instead of rev-parse to work in repos without commits 34 | let output = Command::new("git") 35 | .args(["symbolic-ref", "--short", "HEAD"]) 36 | .output()?; 37 | 38 | if !output.status.success() { 39 | return Err(std::io::Error::other("Failed to get current git branch").into()); 40 | } 41 | 42 | let branch = String::from_utf8(output.stdout)?.trim().to_string(); 43 | 44 | Ok(branch) 45 | } 46 | 47 | #[cfg(test)] 48 | mod tests { 49 | use super::*; 50 | 51 | #[test] 52 | fn test_get_current_branch() { 53 | // This test will only pass in a git repository 54 | // In CI or non-git environments, it might fail 55 | let result = get_current_branch(); 56 | if let Ok(branch) = result { 57 | // Branch name should not be empty 58 | assert!(!branch.is_empty()); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /test/fix_from_ref_to_ref.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | 8 | teardown() { 9 | _common_teardown 10 | } 11 | 12 | @test "hk fix --from-ref and --to-ref fixes files between refs" { 13 | # Create a file and commit it 14 | cat < test1.js 15 | console.log("test1") 16 | EOF 17 | git add test1.js 18 | git commit -m "Add test1.js" 19 | 20 | # Save the first commit hash 21 | FIRST_COMMIT=$(git rev-parse HEAD) 22 | 23 | # Modify the file and commit it 24 | cat < test1.js 25 | console.log("test1 modified") 26 | EOF 27 | git add test1.js 28 | git commit -m "Modify test1.js" 29 | 30 | # Create a new file and commit it 31 | cat < test2.js 32 | console.log("test2") 33 | EOF 34 | git add test2.js 35 | git commit -m "Add test2.js" 36 | 37 | # Save the last commit hash 38 | LAST_COMMIT=$(git rev-parse HEAD) 39 | 40 | # Create the hk.pkl file with prettier 41 | cat < hk.pkl 42 | amends "$PKL_PATH/Config.pkl" 43 | import "$PKL_PATH/Builtins.pkl" 44 | hooks { 45 | ["fix"] { 46 | fix = true 47 | stash = "patch-file" 48 | steps { 49 | ["prettier"] = Builtins.prettier 50 | } 51 | } 52 | } 53 | EOF 54 | 55 | hk fix --from-ref=$FIRST_COMMIT --to-ref=$LAST_COMMIT 56 | 57 | # Verify files were formatted 58 | run cat test1.js 59 | assert_output 'console.log("test1 modified");' 60 | run cat test2.js 61 | assert_output 'console.log("test2");' 62 | 63 | # Create a third file but don't commit it 64 | cat < test3.js 65 | console.log("test3") 66 | EOF 67 | 68 | # Run hk fix with --from-ref and --to-ref again 69 | hk fix --from-ref=$FIRST_COMMIT --to-ref=$LAST_COMMIT 70 | 71 | # Verify test3.js was not formatted 72 | run cat test3.js 73 | assert_output 'console.log("test3")' 74 | } 75 | -------------------------------------------------------------------------------- /test/skip_reason_precedence.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | 8 | teardown() { 9 | _common_teardown 10 | } 11 | 12 | @test "no-files-to-process takes precedence over profile-not-enabled" { 13 | # Ensure there is at least one staged/tracked file so hk does not early-exit 14 | echo hi > SOME_FILE.txt 15 | git add SOME_FILE.txt 16 | 17 | cat < hk.pkl 18 | amends "$PKL_PATH/Config.pkl" 19 | 20 | // Ensure skip reasons are printed for all cases we care about 21 | display_skip_reasons = List("profile-not-enabled", "no-files-to-process", "condition-false") 22 | 23 | hooks { 24 | ["check"] { 25 | steps { 26 | ["demo"] { 27 | // Require a missing profile 28 | profiles = List("slow") 29 | // Ensure this step has no files to process after filtering 30 | dir = "nonexistent_dir" 31 | check = "echo should-not-run" 32 | } 33 | } 34 | } 35 | } 36 | PKL 37 | 38 | run hk check 39 | assert_success 40 | assert_output --partial "skipped: no files to process" 41 | refute_output --partial "skipped: profile" 42 | } 43 | 44 | @test "condition-false takes precedence over profile-not-enabled" { 45 | # Ensure there is at least one staged/tracked file so hk does not early-exit 46 | echo hi > foo.txt 47 | git add foo.txt 48 | 49 | cat < hk.pkl 50 | amends "$PKL_PATH/Config.pkl" 51 | 52 | display_skip_reasons = List("profile-not-enabled", "no-files-to-process", "condition-false") 53 | 54 | hooks { 55 | ["check"] { 56 | steps { 57 | ["demo"] { 58 | profiles = List("slow") 59 | condition = "false" 60 | check = "echo should-not-run" 61 | } 62 | } 63 | } 64 | } 65 | PKL 66 | 67 | run hk check 68 | assert_success 69 | assert_output --partial "skipped: condition is false" 70 | refute_output --partial "skipped: profile" 71 | } 72 | -------------------------------------------------------------------------------- /test/stash_prefers_unstaged_over_fixes.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | 8 | teardown() { 9 | _common_teardown 10 | } 11 | 12 | create_config_with_stash_method() { 13 | local method="$1" 14 | cat < hk.pkl 15 | amends "$PKL_PATH/Config.pkl" 16 | hooks { 17 | ["fix"] { 18 | fix = true 19 | stash = "$method" 20 | steps { 21 | ["overwrite"] { 22 | glob = "conflict.txt" 23 | fix = "printf 'line1\\nfixed\\nline3\\n' > conflict.txt" 24 | stage = "conflict.txt" 25 | } 26 | } 27 | } 28 | } 29 | EOF 30 | git add hk.pkl 31 | git commit -m "init" 32 | } 33 | 34 | prepare_conflict_state() { 35 | # Base file and commit 36 | printf 'line1\nline2\nline3\n' > conflict.txt 37 | git add conflict.txt 38 | git commit -m "base" 39 | 40 | # Stage a change to the same line 41 | printf 'line1\nstaged-change\nline3\n' > conflict.txt 42 | git add conflict.txt 43 | 44 | # Make an unstaged change to the same line 45 | printf 'line1\nunstaged-change\nline3\n' > conflict.txt 46 | } 47 | 48 | assert_unstaged_preferred() { 49 | # After hk fix, we should prefer original unstaged contents 50 | assert_file_contains conflict.txt 'unstaged-change' 51 | run grep -q 'fixed' conflict.txt 52 | assert_failure 53 | run grep -q '<<<<<<<' conflict.txt 54 | assert_failure 55 | run grep -q '>>>>>>>' conflict.txt 56 | assert_failure 57 | } 58 | 59 | @test "stash=git: prefer unstaged over fixer changes on same lines" { 60 | create_config_with_stash_method git 61 | prepare_conflict_state 62 | hk fix -v 63 | assert_unstaged_preferred 64 | } 65 | 66 | @test "stash=patch-file: prefer unstaged over fixer changes on same lines" { 67 | create_config_with_stash_method patch-file 68 | prepare_conflict_state 69 | hk fix -v 70 | assert_unstaged_preferred 71 | } 72 | -------------------------------------------------------------------------------- /docs/public/python-project.pkl: -------------------------------------------------------------------------------- 1 | /// Example configuration for a Python project 2 | /// * Uses ruff for fast linting 3 | /// * Uses ruff_format for fast formatting 4 | /// * Uses mypy for type checking 5 | /// * Sorts imports with isort 6 | /// * Validates with flake8 7 | amends "package://github.com/jdx/hk/releases/download/v1.28.0/hk@1.28.0#/Config.pkl" 8 | import "package://github.com/jdx/hk/releases/download/v1.28.0/hk@1.28.0#/Builtins.pkl" 9 | 10 | local python_linters = new Mapping { 11 | // Ruff is a fast Python linter 12 | ["ruff"] = (Builtins.ruff) { 13 | // Run ruff first as it's the fastest 14 | batch = true 15 | } 16 | // Ruff formatter for code formatting 17 | ["ruff_format"] = (Builtins.ruff_format) { 18 | depends = "ruff" // Run after ruff 19 | } 20 | // Black for consistent formatting (alternative to ruff_format) 21 | ["black"] = (Builtins.black) { 22 | depends = "ruff_format" // Run after ruff_format 23 | } 24 | // isort for import sorting 25 | ["isort"] = (Builtins.isort) { 26 | depends = "black" // Run after black 27 | } 28 | // Type checking with mypy 29 | ["mypy"] = (Builtins.mypy) { 30 | // Type checking doesn't modify files 31 | stomp = true 32 | // Only run with "types" profile 33 | profiles = List("types", "full") 34 | } 35 | // Additional validation with flake8 36 | ["flake8"] = (Builtins.flake8) { 37 | // Only run in CI 38 | profiles = List("ci") 39 | } 40 | } 41 | 42 | hooks { 43 | ["pre-commit"] { 44 | fix = true 45 | stash = "git" 46 | steps = python_linters 47 | } 48 | ["pre-push"] { 49 | // Include type checking on push 50 | steps = python_linters 51 | env { 52 | ["HK_PROFILES"] = "types" 53 | } 54 | } 55 | ["check"] { 56 | steps = python_linters 57 | } 58 | ["fix"] { 59 | fix = true 60 | steps = python_linters 61 | } 62 | } 63 | 64 | // Enable fail-fast for quicker feedback 65 | fail_fast = true 66 | -------------------------------------------------------------------------------- /test/pre_commit_does_not_stash_staged_only_files.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | export HK_SUMMARY_TEXT=1 7 | } 8 | 9 | teardown() { 10 | _common_teardown 11 | } 12 | 13 | # Minimal pre-commit that runs a no-op on *.txt to exercise stash path 14 | create_minimal_precommit() { 15 | cat < hk.pkl 16 | amends "$PKL_PATH/Config.pkl" 17 | hooks { 18 | ["pre-commit"] { 19 | fix = true 20 | stash = "git" 21 | steps = new Mapping { 22 | ["noop"] { 23 | glob = "*.txt" 24 | stage = "*.txt" 25 | fix = "bash -lc 'true'" 26 | } 27 | } 28 | } 29 | } 30 | PKL 31 | git add hk.pkl 32 | git -c commit.gpgsign=false commit -m "init hk" 33 | hk install 34 | } 35 | 36 | # Prepare a file with ONLY staged changes; no unstaged hunks 37 | prepare_only_staged_change() { 38 | printf 'base\n' > a.txt 39 | git add a.txt 40 | git -c commit.gpgsign=false commit -m "base" 41 | 42 | printf 'staged\n' > a.txt 43 | git add a.txt 44 | 45 | # Sanity: there must be no unstaged diffs 46 | run bash -lc "git diff -- a.txt" 47 | assert_success 48 | assert_output "" 49 | } 50 | 51 | @test "pre-commit does not stash when there are no unstaged hunks" { 52 | create_minimal_precommit 53 | prepare_only_staged_change 54 | 55 | # Run hook and capture verbose output 56 | run bash -lc 'HK_LOG=debug hk run pre-commit || true' 57 | echo "$output" 58 | 59 | # Expectation: hk should detect no unstaged changes and avoid stashing 60 | # Current buggy behavior stashes anyway; assert the correct behavior so this fails now. 61 | # Use status as ground truth: no worktree changes should have been introduced either 62 | run bash -lc 'git status --porcelain --untracked-files=all' 63 | assert_success 64 | assert_output --partial "M a.txt" 65 | refute_output --partial " M a.txt" 66 | } 67 | -------------------------------------------------------------------------------- /src/ui/style.rs: -------------------------------------------------------------------------------- 1 | #![allow(unused)] 2 | 3 | use console::{StyledObject, style}; 4 | 5 | pub fn ereset() -> String { 6 | if console::colors_enabled_stderr() { 7 | "\x1b[0m".to_string() 8 | } else { 9 | "".to_string() 10 | } 11 | } 12 | 13 | pub fn estyle(val: D) -> StyledObject { 14 | style(val).for_stderr() 15 | } 16 | 17 | pub fn ecyan(val: D) -> StyledObject { 18 | estyle(val).cyan() 19 | } 20 | 21 | pub fn eblue(val: D) -> StyledObject { 22 | estyle(val).blue() 23 | } 24 | 25 | pub fn emagenta(val: D) -> StyledObject { 26 | estyle(val).magenta() 27 | } 28 | 29 | pub fn egreen(val: D) -> StyledObject { 30 | estyle(val).green() 31 | } 32 | 33 | pub fn eyellow(val: D) -> StyledObject { 34 | estyle(val).yellow() 35 | } 36 | 37 | pub fn ered(val: D) -> StyledObject { 38 | estyle(val).red() 39 | } 40 | 41 | pub fn eblack(val: D) -> StyledObject { 42 | estyle(val).black() 43 | } 44 | 45 | pub fn eunderline(val: D) -> StyledObject { 46 | estyle(val).underlined() 47 | } 48 | 49 | pub fn edim(val: D) -> StyledObject { 50 | estyle(val).dim() 51 | } 52 | 53 | pub fn ebold(val: D) -> StyledObject { 54 | estyle(val).bold() 55 | } 56 | 57 | pub fn nstyle(val: D) -> StyledObject { 58 | style(val).for_stdout() 59 | } 60 | 61 | pub fn nblue(val: D) -> StyledObject { 62 | nstyle(val).blue() 63 | } 64 | 65 | pub fn ncyan(val: D) -> StyledObject { 66 | nstyle(val).cyan() 67 | } 68 | 69 | pub fn nbold(val: D) -> StyledObject { 70 | nstyle(val).bold() 71 | } 72 | 73 | pub fn nunderline(val: D) -> StyledObject { 74 | nstyle(val).underlined() 75 | } 76 | 77 | pub fn nyellow(val: D) -> StyledObject { 78 | nstyle(val).yellow() 79 | } 80 | 81 | pub fn nred(val: D) -> StyledObject { 82 | nstyle(val).red() 83 | } 84 | 85 | pub fn ndim(val: D) -> StyledObject { 86 | nstyle(val).dim() 87 | } 88 | -------------------------------------------------------------------------------- /test/newline_stripping_bug.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | 8 | teardown() { 9 | _common_teardown 10 | } 11 | 12 | @test "fix preserves exact bytes when index has trailing newline" { 13 | # This test demonstrates the .read() stripping bug in origin/main 14 | # The bug: git_cmd().read() strips trailing newlines from git cat-file output 15 | 16 | cat < hk.pkl 17 | amends "$PKL_PATH/Config.pkl" 18 | hooks { 19 | ["fix"] { 20 | fix = true 21 | stash = "git" 22 | steps = new Mapping { 23 | ["formatter"] { 24 | glob = "test.txt" 25 | stage = "test.txt" 26 | // Formatter that adds a prefix 27 | fix = "sed 's/^/PREFIX:/' test.txt > test.tmp && mv test.tmp test.txt" 28 | } 29 | } 30 | } 31 | } 32 | PKL 33 | git add hk.pkl 34 | git -c commit.gpgsign=false commit -m "init hk" 35 | hk install 36 | 37 | # Create base 38 | echo "base" > test.txt 39 | git add test.txt 40 | git -c commit.gpgsign=false commit -m "base" 41 | 42 | # Stage content with trailing newline 43 | printf 'staged\n' > test.txt 44 | git add test.txt 45 | 46 | # Worktree adds extra line 47 | printf 'staged\nworktree\n' > test.txt 48 | 49 | # Run fix with trace to see the bug 50 | run bash -c "HK_LOG_LEVEL=trace hk fix 2>&1 | grep 'manual-unstash.*ends_i=' | head -1" 51 | echo "Debug output: $output" 52 | 53 | # The bug: ends_i should be true (staged content ends with \n) 54 | # but .read() strips it, making ends_i=false 55 | if [[ "$output" == *"ends_i=false"* ]]; then 56 | echo "BUG DETECTED: Index newline was stripped (ends_i=false)" 57 | # This is the bug in origin/main 58 | [[ "$output" == *"ends_i=true"* ]] # This will fail on origin/main 59 | else 60 | echo "FIX WORKING: Index newline preserved (ends_i=true)" 61 | assert_success 62 | fi 63 | } 64 | -------------------------------------------------------------------------------- /src/step_test.rs: -------------------------------------------------------------------------------- 1 | use indexmap::IndexMap; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)] 5 | #[serde(rename_all = "snake_case")] 6 | #[cfg_attr(debug_assertions, serde(deny_unknown_fields))] 7 | pub struct StepTest { 8 | /// One of: "check" or "fix" 9 | #[serde(default)] 10 | pub run: RunKind, 11 | /// Files to pass into the template context ({{ files }}) 12 | /// If omitted, defaults to keys from `write` 13 | pub files: Option>, 14 | /// Optional path to copy into a temporary sandbox before running 15 | pub fixture: Option, 16 | /// Inline files to create in the sandbox before running 17 | #[serde(default)] 18 | pub write: IndexMap, 19 | /// Additional environment just for this test 20 | #[serde(default)] 21 | pub env: IndexMap, 22 | /// Expected result of running the test 23 | #[serde(default)] 24 | pub expect: StepTestExpect, 25 | /// Command to run before executing the test command 26 | pub before: Option, 27 | /// Command to run after the main command, before evaluating expectations 28 | pub after: Option, 29 | } 30 | 31 | #[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq, Eq)] 32 | #[serde(rename_all = "snake_case")] 33 | pub enum RunKind { 34 | #[default] 35 | Check, 36 | Fix, 37 | } 38 | 39 | #[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)] 40 | #[serde(rename_all = "snake_case")] 41 | #[cfg_attr(debug_assertions, serde(deny_unknown_fields))] 42 | pub struct StepTestExpect { 43 | #[serde(default)] 44 | pub code: i32, 45 | /// Substring which must appear in stdout 46 | pub stdout: Option, 47 | /// Substring which must appear in stderr 48 | pub stderr: Option, 49 | /// Map of path -> full expected file contents (exact match) 50 | #[serde(default)] 51 | pub files: IndexMap, 52 | } 53 | -------------------------------------------------------------------------------- /test/skip_output.bats: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bats 2 | 3 | setup() { 4 | load 'test_helper/common_setup' 5 | _common_setup 6 | } 7 | 8 | teardown() { 9 | _common_teardown 10 | } 11 | 12 | @test "skip output: disabled by profile" { 13 | cat < hk.pkl 14 | amends "$PKL_PATH/Config.pkl" 15 | hooks { 16 | ["check"] { 17 | steps { 18 | ["foo"] { 19 | profiles = List("needs-profile") 20 | check = "echo 'RUN'" 21 | } 22 | } 23 | } 24 | } 25 | EOF 26 | run hk check 27 | assert_success 28 | assert_output --partial "foo – skipped: profile not enabled (needs-profile)" 29 | refute_output --partial "RUN" 30 | } 31 | 32 | @test "skip output: HK_SKIP_STEPS" { 33 | cat < hk.pkl 34 | amends "$PKL_PATH/Config.pkl" 35 | // Need to explicitly enable disabled-by-env skip messages since default is only profile-not-enabled 36 | display_skip_reasons = List("profile-not-enabled", "disabled-by-env") 37 | hooks { 38 | ["check"] { 39 | steps { 40 | ["foo"] { 41 | check = "echo 'RUN'" 42 | } 43 | } 44 | } 45 | } 46 | EOF 47 | HK_SKIP_STEPS=foo run hk check 48 | assert_success 49 | assert_output --partial "foo – skipped: disabled via HK_SKIP_STEPS" 50 | refute_output --partial "RUN" 51 | } 52 | 53 | @test "skip output: condition false" { 54 | cat < hk.pkl 55 | amends "$PKL_PATH/Config.pkl" 56 | // Need to explicitly enable condition-false skip messages since default is only profile-not-enabled 57 | display_skip_reasons = List("profile-not-enabled", "condition-false") 58 | hooks { 59 | ["check"] { 60 | steps { 61 | ["foo"] { 62 | condition = "false" 63 | check = "echo 'RUN'" 64 | } 65 | } 66 | } 67 | } 68 | EOF 69 | run hk check 70 | assert_success 71 | assert_output --partial "foo – skipped: condition is false" 72 | refute_output --partial "RUN" 73 | } 74 | -------------------------------------------------------------------------------- /docs/about.md: -------------------------------------------------------------------------------- 1 | # About 2 | 3 | hk is built by [@jdx](https://github.com/jdx). 4 | 5 | ## Why does this exist? 6 | 7 | git hooks need to be fast above all else or else developers won't use them. Parallelism 8 | is the best (and likely only) way to achieve acceptable performance at the git hook manager level. 9 | 10 | Existing alternatives to hk such as [lefthook](https://github.com/evilmartians/lefthook) support 11 | very basic parallel execution of shell script however because linters may edit files—this naive approach 12 | can break down if multiple linters affect the same file. 13 | 14 | I felt that a git hook manager that had tighter integration with the linters would be able to leverage 15 | read/write file locks to enable more aggressive parallelism while preventing race conditions. This read/write locking is the primary reason 16 | I built hk, however there are other design decisions worth noting that I think makes hk a better experience than its peers: 17 | 18 | - hk has a bunch of [builtins](https://github.com/jdx/hk/tree/main/pkl/builtins) you can use for common linters like `prettier` or `black`. 19 | - hk stashes unstaged changes before running "fix" hooks. This prevents a common issue with pre-commit hooks where files containing both staged and 20 | unstaged changes get modified and the unstaged changes end up being staged erroneously. 21 | - By default, hk uses libgit2 to directly interact with git instead of shelling out many times to `git`. 22 | (This generally makes hk much faster but there are situations like `fsmonitor` where it may perform worse) 23 | - hk is a Rust CLI which gives it great startup performance. 24 | - hk is designed to work well with my other project [mise-en-place](https://mise.jdx.dev) which makes it easy to manage dependencies for hk linters. 25 | 26 | ## Contributing 27 | 28 | Contributions are welcome! Please open an issue or submit a PR. I always encourage reaching out to me first before submitting a feature PR to make sure it's something I will be interested in 29 | maintaining. 30 | -------------------------------------------------------------------------------- /docs/cli/run/prepare-commit-msg.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk run prepare-commit-msg` 4 | 5 | - **Usage**: `hk run prepare-commit-msg [FLAGS] …` 6 | - **Aliases**: `pcm` 7 | 8 | ## Arguments 9 | 10 | ### `` 11 | 12 | The path to the file that contains the commit message so far 13 | 14 | ### `[SOURCE]` 15 | 16 | The source of the commit message (e.g., "message", "template", "merge") 17 | 18 | ### `[SHA]` 19 | 20 | The SHA of the commit being amended (if applicable) 21 | 22 | ### `[FILES]…` 23 | 24 | Run on specific files 25 | 26 | ## Flags 27 | 28 | ### `-a --all` 29 | 30 | Run on all files instead of just staged files 31 | 32 | ### `-c --check` 33 | 34 | Run check command instead of fix command 35 | 36 | ### `-e --exclude… ` 37 | 38 | Exclude files that otherwise would have been selected 39 | 40 | ### `-f --fix` 41 | 42 | Run fix command instead of check command (this is the default behavior unless HK_FIX=0) 43 | 44 | ### `-g --glob… ` 45 | 46 | Run on files that match these glob patterns 47 | 48 | ### `-P --plan` 49 | 50 | Print the plan instead of running the hook 51 | 52 | ### `-S --step… ` 53 | 54 | Run only specific step(s) 55 | 56 | ### `--fail-fast` 57 | 58 | Abort on first failure 59 | 60 | ### `--from-ref ` 61 | 62 | Start reference for checking files (requires --to-ref) 63 | 64 | ### `--no-fail-fast` 65 | 66 | Continue on failures (opposite of --fail-fast) 67 | 68 | ### `--no-stage` 69 | 70 | Disable auto-staging of fixed files 71 | 72 | ### `--skip-step… ` 73 | 74 | Skip specific step(s) 75 | 76 | ### `--stage` 77 | 78 | Enable auto-staging of fixed files 79 | 80 | ### `--stash ` 81 | 82 | Stash method to use for git hooks 83 | 84 | **Choices:** 85 | 86 | - `git` 87 | - `patch-file` 88 | - `none` 89 | 90 | ### `--stats` 91 | 92 | Display statistics about files matching each step 93 | 94 | ### `--to-ref ` 95 | 96 | End reference for checking files (requires --from-ref) 97 | -------------------------------------------------------------------------------- /docs/cli/run.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # `hk run` 4 | 5 | - **Usage**: `hk run [FLAGS] [FILES]… ` 6 | - **Aliases**: `r` 7 | 8 | Run a hook 9 | 10 | ## Arguments 11 | 12 | ### `[FILES]…` 13 | 14 | Run on specific files 15 | 16 | ## Flags 17 | 18 | ### `-a --all` 19 | 20 | Run on all files instead of just staged files 21 | 22 | ### `-c --check` 23 | 24 | Run check command instead of fix command 25 | 26 | ### `-e --exclude… ` 27 | 28 | Exclude files that otherwise would have been selected 29 | 30 | ### `-f --fix` 31 | 32 | Run fix command instead of check command (this is the default behavior unless HK_FIX=0) 33 | 34 | ### `-g --glob… ` 35 | 36 | Run on files that match these glob patterns 37 | 38 | ### `-P --plan` 39 | 40 | Print the plan instead of running the hook 41 | 42 | ### `-S --step… ` 43 | 44 | Run only specific step(s) 45 | 46 | ### `--fail-fast` 47 | 48 | Abort on first failure 49 | 50 | ### `--from-ref ` 51 | 52 | Start reference for checking files (requires --to-ref) 53 | 54 | ### `--no-fail-fast` 55 | 56 | Continue on failures (opposite of --fail-fast) 57 | 58 | ### `--no-stage` 59 | 60 | Disable auto-staging of fixed files 61 | 62 | ### `--skip-step… ` 63 | 64 | Skip specific step(s) 65 | 66 | ### `--stage` 67 | 68 | Enable auto-staging of fixed files 69 | 70 | ### `--stash ` 71 | 72 | Stash method to use for git hooks 73 | 74 | **Choices:** 75 | 76 | - `git` 77 | - `patch-file` 78 | - `none` 79 | 80 | ### `--stats` 81 | 82 | Display statistics about files matching each step 83 | 84 | ### `--to-ref ` 85 | 86 | End reference for checking files (requires --from-ref) 87 | 88 | ## Subcommands 89 | 90 | - [`hk run commit-msg [FLAGS] [FILES]…`](/cli/run/commit-msg.md) 91 | - [`hk run pre-commit [FLAGS] [FILES]…`](/cli/run/pre-commit.md) 92 | - [`hk run pre-push [FLAGS] [ARGS]…`](/cli/run/pre-push.md) 93 | - [`hk run prepare-commit-msg [FLAGS] …`](/cli/run/prepare-commit-msg.md) 94 | --------------------------------------------------------------------------------