{"id":50248297,"url":"https://github.com/sgerrand/git_hoox","last_synced_at":"2026-05-28T01:01:00.240Z","repository":{"id":359367512,"uuid":"1245748550","full_name":"sgerrand/git_hoox","owner":"sgerrand","description":"Support for git hooks in pure Elixir","archived":false,"fork":false,"pushed_at":"2026-05-26T22:29:03.000Z","size":209,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-27T00:20:52.196Z","etag":null,"topics":["elixir-library","git-hooks","hex-package"],"latest_commit_sha":null,"homepage":"https://hexdocs.pm/git_hoox","language":"Elixir","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"bsd-2-clause","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/sgerrand.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":".github/FUNDING.yml","license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":".github/CODEOWNERS","security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null},"funding":{"github":"sgerrand","patreon":"sgerrand"}},"created_at":"2026-05-21T14:14:53.000Z","updated_at":"2026-05-26T22:29:08.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/sgerrand/git_hoox","commit_stats":null,"previous_names":["sgerrand/git_hoox"],"tags_count":5,"template":false,"template_full_name":null,"purl":"pkg:github/sgerrand/git_hoox","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sgerrand%2Fgit_hoox","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sgerrand%2Fgit_hoox/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sgerrand%2Fgit_hoox/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sgerrand%2Fgit_hoox/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/sgerrand","download_url":"https://codeload.github.com/sgerrand/git_hoox/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sgerrand%2Fgit_hoox/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33589684,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-05-27T02:00:06.184Z","response_time":53,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["elixir-library","git-hooks","hex-package"],"created_at":"2026-05-27T00:01:46.426Z","updated_at":"2026-05-28T01:01:00.169Z","avatar_url":"https://github.com/sgerrand.png","language":"Elixir","funding_links":["https://github.com/sponsors/sgerrand","https://patreon.com/sgerrand"],"categories":[],"sub_categories":[],"readme":"# GitHoox\n\nGit hooks in pure Elixir. Configurable file globs, per-hook options, built-in\nsupport for `mix format`, Credo, ExUnit, and Dialyzer.\n\nGitHoox aims for parity with [lefthook](https://github.com/evilmartians/lefthook)'s\nmental model — no implicit stashing, opt-in re-staging of fixed files — while\nkeeping the entire toolchain in Elixir so projects do not need a Node or Python\nruntime to run hooks.\n\n## Installation\n\nAdd `git_hoox` to your dev dependencies in `mix.exs`:\n\n\u003c!-- x-release-please-start-version --\u003e\n```elixir\ndef deps do\n  [\n    {:git_hoox, \"~\u003e 0.3.0\", only: [:dev], runtime: false}\n  ]\nend\n```\n\u003c!-- x-release-please-end --\u003e\n\n\nFetch and install the git hook shims:\n\n```sh\nmix deps.get\nmix git_hoox.install\n```\n\nThe installer writes shims into `.git/hooks/` and refuses to overwrite any\nexisting user-authored hook. Pass `--force` to back up the existing hook\n(saved as `\u003chook\u003e.backup.\u003cutc-timestamp\u003e`) and replace it.\n\n```sh\nmix git_hoox.install --force\nmix git_hoox.install --dry-run    # show the install plan, write nothing\nmix git_hoox.install --scaffold   # also write a starter .git_hoox.exs\n```\n\nPass `--scaffold` (or `-s`) on first install to drop a starter\n`.git_hoox.exs` at the repo root. The scaffolder refuses to overwrite an\nexisting config unless `--force` is set.\n\n## Configuration\n\nGitHoox reads `.git_hoox.exs` at the repo root. The file is a single map.\n\n```elixir\n# .git_hoox.exs\n%{\n  hooks: [\n    pre_commit: [\n      {GitHoox.Hooks.Format, []},\n      {GitHoox.Hooks.Credo, []}\n    ],\n    pre_push: [\n      {GitHoox.Hooks.Test, scope: :stale},\n      {GitHoox.Hooks.Dialyzer, []}\n    ]\n  ],\n  parallel: false,\n  fail_fast: false\n}\n```\n\nTop-level options:\n\n| Key         | Type    | Default | Description                                     |\n|-------------|---------|---------|-------------------------------------------------|\n| `hooks`     | keyword | —       | Per-stage list of `{Module, opts}` entries.     |\n| `parallel`  | boolean | `false` | Run hooks within a stage concurrently.          |\n| `fail_fast` | boolean | `false` | Stop on first failure within a stage.           |\n| `skip_env`  | string  | `\"GIT_HOOX\"` | Env var consulted for skip/exclude flags.  |\n\nSupported stages: `pre_commit`, `prepare_commit_msg`, `commit_msg`,\n`post_commit`, `pre_rebase`, `post_checkout`, `post_merge`, `pre_push`.\n\n## Built-in Hooks\n\n### `GitHoox.Hooks.Format`\n\nRuns `mix format` against staged Elixir files and re-stages the result.\n\n```elixir\n{GitHoox.Hooks.Format, []}\n{GitHoox.Hooks.Format, check_only: true}     # fail instead of mutating\n{GitHoox.Hooks.Format, files: ~w(lib/**/*.ex)}\n```\n\nDefaults: `stage_fixed: true`, `files: ~w(**/*.ex **/*.exs **/*.heex)`.\n\n### `GitHoox.Hooks.Credo`\n\nRuns `mix credo` against staged Elixir files.\n\n```elixir\n{GitHoox.Hooks.Credo, []}\n{GitHoox.Hooks.Credo, strict: true}\n```\n\nDefaults: `stage_fixed: false`, `files: ~w(lib/**/*.ex test/**/*.exs)`.\n\n### `GitHoox.Hooks.Test`\n\nRuns `mix test`. Three selection strategies:\n\n```elixir\n{GitHoox.Hooks.Test, scope: :all}        # full suite\n{GitHoox.Hooks.Test, scope: :stale}      # mix test --stale (fastest)\n{GitHoox.Hooks.Test, scope: :related}    # map staged lib/*.ex to test/*_test.exs\n```\n\nDefaults: `stage_fixed: false`, `scope: :all`.\n\n### `GitHoox.Hooks.Dialyzer`\n\nRuns `mix dialyzer --quiet`. **Slow** — PLT builds and whole-project analysis\nmake this unsuitable for `pre_commit`. Configure on `pre_push`.\n\n```elixir\npre_push: [\n  {GitHoox.Hooks.Dialyzer, []}\n]\n```\n\n### `GitHoox.Hooks.Shell`\n\nEscape hatch for anything not covered by a built-in:\n\n```elixir\n{GitHoox.Hooks.Shell,\n  run: \"mix sobelow --exit\",\n  files: ~w(lib/**/*.ex)}\n\n{GitHoox.Hooks.Shell,\n  run: \"mix format {staged_files}\",\n  files: ~w(*.ex *.exs),\n  stage_fixed: true}\n```\n\nTemplate variables expanded in `:run`:\n\n| Variable          | Source                                            |\n|-------------------|---------------------------------------------------|\n| `{files}`         | paths passed to the hook (stage-specific)         |\n| `{staged_files}`  | `git diff --cached --name-only --diff-filter=ACMR` |\n| `{all_files}`     | `git ls-files`                                    |\n| `{push_files}`    | paths parsed from `pre_push` stdin (pre_push only) |\n\n`{files}` and `{staged_files}` are distinct. `{files}` is whatever the\nstage hands the hook (staged paths for `pre_commit`, the commit message\nfile for `commit_msg`, etc.), while `{staged_files}` always re-runs\n`git diff --cached` regardless of stage. If a template references\n`{files}` or `{push_files}` and the hook is invoked with no files — or\nreferences `{staged_files}` and `git diff --cached` returns nothing —\nthe hook returns `:ok` without invoking the shell so commands like\n`mix sobelow {files}` cannot silently scan the entire project when the\nsubstitution would have collapsed to an empty argument.\n\n`{push_files}` only makes sense in the `pre_push` stage. Using it on any\nother stage causes the hook to return an error so misconfigurations\nsurface immediately at first dispatch rather than silently expanding to\nan empty string.\n\n## Custom Hooks\n\nImplement the `GitHoox.Hook` behaviour:\n\n```elixir\ndefmodule MyApp.Hooks.Sobelow do\n  @behaviour GitHoox.Hook\n\n  @opts_schema [\n    confidence: [type: :string, default: \"Low\",\n                 doc: \"Minimum severity that fails the build.\"]\n  ]\n\n  @impl true\n  def default_opts, do: [files: ~w(lib/**/*.ex), stage_fixed: false]\n\n  @impl true\n  def opts_schema, do: @opts_schema\n\n  @impl true\n  def run([], _opts), do: :ok\n  def run(files, opts) do\n    args = [\"sobelow\", \"--exit\", Keyword.fetch!(opts, :confidence) | files]\n\n    case System.cmd(\"mix\", args, stderr_to_stdout: true) do\n      {_, 0} -\u003e :ok\n      {out, code} -\u003e {:error, {code, out}}\n    end\n  end\nend\n```\n\nThe optional `opts_schema/0` callback declares a\n[NimbleOptions](https://hexdocs.pm/nimble_options) schema for any keys\nnot part of the global hook schema (`:files`, `:stage_fixed`, `:timeout`,\n`:env`). Unknown keys, missing required keys, and wrong types surface at\n`mix git_hoox.doctor` and `mix git_hoox.run` config-load time. Hooks\nthat do not implement the callback continue to accept arbitrary extras\nwithout validation.\n\nRegister in `.git_hoox.exs`:\n\n```elixir\npre_commit: [\n  {MyApp.Hooks.Sobelow, []}\n]\n```\n\nThe [`examples/`](https://github.com/sgerrand/git_hoox/tree/main/examples) directory ships ready-to-copy custom hooks\n(Sobelow, ExCoveralls coverage threshold, JIRA ticket enforcement).\n\nReturn values:\n\n| Return                  | Meaning                                                  |\n|-------------------------|----------------------------------------------------------|\n| `:ok`                   | Hook passed, no files modified.                          |\n| `{:ok, modified_paths}` | Hook passed; runner re-stages paths if `stage_fixed: true`. |\n| `:skip`                 | Hook deliberately did nothing.                           |\n| `{:error, reason}`      | Hook failed. Commit aborts unless `fail_fast: false` and other hooks need to run. |\n\n## Partial Stage and `stage_fixed`\n\nGitHoox does **not** stash unstaged changes before running hooks. Hooks see the\nworking tree as-is. This matches lefthook's default behavior and avoids the\ncrash and conflict risks of automatic `git stash`/`git stash pop` wrappers.\n\nWhen a formatter or autofixer mutates a file, set `stage_fixed: true` on that\nhook entry to re-`git add` the modified files automatically. Built-in formatter\nhooks set this default; opt out per-entry if undesired.\n\n## Skipping Hooks\n\nSet the configured `skip_env` (default `GIT_HOOX`) at commit time:\n\n```sh\nGIT_HOOX=0 git commit                       # disable all hooks\nGIT_HOOX_EXCLUDE=credo,format git commit    # skip specific hook modules\nGIT_HOOX_ONLY=test git push                 # run only one\n```\n\nModule names match the suffix after `GitHoox.Hooks.` (lowercased).\n\n## Hook Output\n\nHooks stream their combined stdout/stderr to the terminal as it arrives,\nso a long-running `mix dialyzer` or `mix test` shows progress instead of\nfifteen seconds of silence followed by a single error blob.\n\nStreaming is on by default. To suppress it (for example in scripted\ncontexts where you want only the runner's exit code), set the\napplication env in your `config/config.exs`:\n\n```elixir\nconfig :git_hoox, stream_output: false\n```\n\n`parallel: true` mode buffers each hook's output and flushes it as a\nsingle block once the hook finishes, in completion order. Output stays\nreadable — no chunk-level interleaving — but you pay for it in latency:\nnothing appears on the terminal until the fastest hook completes. If\nlive progress matters more than tidy output, stay on serial dispatch.\n\n## Observability\n\nGitHoox emits `:telemetry` events around every stage and every hook, with no\ndefault handler attached. Attach the reference `Logger`-backed handler with\n`GitHoox.Logger.attach/0`, or roll your own — the event shape is documented\non `GitHoox.Telemetry`.\n\n```elixir\n# Reference Logger output.\nGitHoox.Logger.attach()\n\n# Or a custom one, e.g. for shipping timings to a metrics backend.\n:telemetry.attach(\n  \"git-hoox-timings\",\n  [:git_hoox, :hook, :stop],\n  fn _ev, %{duration: d}, %{module: mod, result: r}, _ -\u003e\n    ms = System.convert_time_unit(d, :native, :millisecond)\n    :ok = MyMetrics.observe(\"git_hoox.hook\", ms, mod: mod, result: r)\n  end,\n  nil\n)\n```\n\n## Diagnose Setup Issues\n\n```sh\nmix git_hoox.doctor\n```\n\nReports the state of the git repo, hooks directory, installed shims,\nconfig file, and config validity. Exits non-zero only on hard errors\n(e.g. malformed `.git_hoox.exs`); missing shims or missing config surface\nas `[warn]` lines so the task is safe to run from CI as a sanity check.\n\n## Inspect Resolved Config\n\n```sh\nmix git_hoox.list\n```\n\nLoads `.git_hoox.exs`, merges each hook's `default_opts/0` with your\noverrides, and prints the result grouped by stage. Useful for confirming\nthat an opt you set is actually being passed to the hook.\n\n## Benchmark Hooks\n\n```sh\nmix git_hoox.bench                      # pre_commit, 5 runs\nmix git_hoox.bench --stage pre_push     # different stage\nmix git_hoox.bench --runs 20            # more samples\nmix git_hoox.bench -s commit-msg -n 3\n```\n\nAttaches a `:telemetry` handler, dispatches `mix git_hoox.run \u003cstage\u003e`\nthe requested number of times, and prints per-hook timing statistics\n(`runs`, `errors`, `p50`, `p95`, `max`, `mean`, `total`) sorted by total\ntime. Use it when deciding whether a hook is cheap enough to keep on\n`pre_commit` or should move to `pre_push`.\n\n## Uninstall\n\n```sh\nmix git_hoox.uninstall\n```\n\nRemoves only the shims GitHoox installed (identified by a marker comment).\nForeign hooks are left untouched. If a `.backup.*` file exists alongside a\nremoved shim, the most recent backup is restored.\n\n## Status\n\nGitHoox is pre-1.0. The public API surface (`GitHoox`, `GitHoox.Hook`,\n`GitHoox.Config`, `GitHoox.Git`, `GitHoox.Installer`, the built-in hook modules,\nand the `mix git_hoox.*` tasks) follows semver from 0.1.0 onward, but internals\nunder modules marked `@moduledoc false` (e.g. config schema) may change without notice.\n\nPre-releases are cut on demand via the Pre-release GitHub Action\n(`workflow_dispatch`) and published to Hex with the standard\n`-rc.N`/`-beta.N`/`-alpha.N` semver suffix, so you can pin a release\ncandidate with `{:git_hoox, \"0.2.0-rc.1\"}` before the stable cut. A\nrolling `-next.N` channel is also supported — dispatching the workflow\nwith version `0.3.0-next` auto-increments the trailing counter from the\nexisting tags on origin.\n\nDocumentation is published to [HexDocs](https://hexdocs.pm/git_hoox).\n\n## Changelog\n\nReleased versions are recorded in [CHANGELOG.md](CHANGELOG.md), generated by\n[release-please](https://github.com/googleapis/release-please).\n\nUnreleased changes accumulate in the open\n[Release PR](https://github.com/sgerrand/git_hoox/pulls?q=is%3Apr+is%3Aopen+label%3A%22autorelease%3A+pending%22),\nwhich release-please refreshes on every push to `main` and rewrites the\nupcoming version and CHANGELOG entries into.\n\n## License\n\nBSD 2-Clause. See [LICENSE](LICENSE).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsgerrand%2Fgit_hoox","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fsgerrand%2Fgit_hoox","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsgerrand%2Fgit_hoox/lists"}