Git hooks in pure Elixir. Configurable file globs, per-hook options, built-in support for mix format, Credo, ExUnit, and Dialyzer.

GitHoox aims for parity with lefthook's mental model — no implicit stashing, opt-in re-staging of fixed files — while keeping the entire toolchain in Elixir so projects do not need a Node or Python runtime to run hooks.

Installation

Add git_hoox to your dev dependencies in mix.exs:

def deps do
  [
    {:git_hoox, "~> 0.4.1", only: [:dev], runtime: false}
  ]
end

Fetch and install the git hook shims:

mix deps.get
mix git_hoox.install

The installer writes shims into .git/hooks/ and refuses to overwrite any existing user-authored hook. Pass --force to back up the existing hook (saved as <hook>.backup.<utc-timestamp>) and replace it.

mix git_hoox.install --force
mix git_hoox.install --dry-run    # show the install plan, write nothing
mix git_hoox.install --scaffold   # also write a starter .git_hoox.exs

Pass --scaffold (or -s) on first install to drop a starter .git_hoox.exs at the repo root. The scaffolder refuses to overwrite an existing config unless --force is set.

Configuration

GitHoox reads .git_hoox.exs at the repo root. The file is a single map.

# .git_hoox.exs
%{
  hooks: [
    pre_commit: [
      {GitHoox.Hooks.Format, []},
      {GitHoox.Hooks.Credo, []}
    ],
    pre_push: [
      {GitHoox.Hooks.Test, scope: :stale},
      {GitHoox.Hooks.Dialyzer, []}
    ]
  ],
  parallel: false,
  fail_fast: false
}

Top-level options:

KeyTypeDefaultDescription
hookskeywordPer-stage list of {Module, opts} entries.
parallelbooleanfalseRun hooks within a stage concurrently.
fail_fastbooleanfalseStop on first failure within a stage.
skip_envstring"GIT_HOOX"Env var consulted for skip/exclude flags.

Supported stages: pre_commit, prepare_commit_msg, commit_msg, post_commit, pre_rebase, post_checkout, post_merge, pre_push.

Built-in Hooks

GitHoox.Hooks.Format

Runs mix format against staged Elixir files and re-stages the result.

{GitHoox.Hooks.Format, []}
{GitHoox.Hooks.Format, check_only: true}     # fail instead of mutating
{GitHoox.Hooks.Format, files: ~w(lib/**/*.ex)}

Defaults: stage_fixed: true, files: ~w(**/*.ex **/*.exs **/*.heex).

GitHoox.Hooks.Credo

Runs mix credo against staged Elixir files.

{GitHoox.Hooks.Credo, []}
{GitHoox.Hooks.Credo, strict: true}

Defaults: stage_fixed: false, files: ~w(lib/**/*.ex test/**/*.exs).

GitHoox.Hooks.Test

Runs mix test. Three selection strategies:

{GitHoox.Hooks.Test, scope: :all}        # full suite
{GitHoox.Hooks.Test, scope: :stale}      # mix test --stale (fastest)
{GitHoox.Hooks.Test, scope: :related}    # map staged lib/*.ex to test/*_test.exs

Defaults: stage_fixed: false, scope: :all.

GitHoox.Hooks.Dialyzer

Runs mix dialyzer --quiet. Slow — PLT builds and whole-project analysis make this unsuitable for pre_commit. Configure on pre_push.

pre_push: [
  {GitHoox.Hooks.Dialyzer, []}
]

GitHoox.Hooks.Shell

Escape hatch for anything not covered by a built-in:

{GitHoox.Hooks.Shell,
  run: "mix sobelow --exit",
  files: ~w(lib/**/*.ex)}

{GitHoox.Hooks.Shell,
  run: "mix format {staged_files}",
  files: ~w(*.ex *.exs),
  stage_fixed: true}

Template variables expanded in :run:

VariableSource
{files}paths passed to the hook (stage-specific)
{staged_files}git diff --cached --name-only --diff-filter=ACMR
{all_files}git ls-files
{push_files}paths parsed from pre_push stdin (pre_push only)

{files} and {staged_files} are distinct. {files} is whatever the stage hands the hook (staged paths for pre_commit, the commit message file for commit_msg, etc.), while {staged_files} always re-runs git diff --cached regardless of stage. If a template references {files} or {push_files} and the hook is invoked with no files — or references {staged_files} and git diff --cached returns nothing — the hook returns :ok without invoking the shell so commands like mix sobelow {files} cannot silently scan the entire project when the substitution would have collapsed to an empty argument.

{push_files} only makes sense in the pre_push stage. Using it on any other stage causes the hook to return an error so misconfigurations surface immediately at first dispatch rather than silently expanding to an empty string.

Custom Hooks

Implement the GitHoox.Hook behaviour:

defmodule MyApp.Hooks.Sobelow do
  @behaviour GitHoox.Hook

  @opts_schema [
    confidence: [type: :string, default: "Low",
                 doc: "Minimum severity that fails the build."]
  ]

  @impl true
  def default_opts, do: [files: ~w(lib/**/*.ex), stage_fixed: false]

  @impl true
  def opts_schema, do: @opts_schema

  @impl true
  def run([], _opts), do: :ok
  def run(files, opts) do
    args = ["sobelow", "--exit", Keyword.fetch!(opts, :confidence) | files]

    case System.cmd("mix", args, stderr_to_stdout: true) do
      {_, 0} -> :ok
      {out, code} -> {:error, {code, out}}
    end
  end
end

The optional opts_schema/0 callback declares a NimbleOptions schema for any keys not part of the global hook schema (:files, :stage_fixed, :timeout, :env). Unknown keys, missing required keys, and wrong types surface at mix git_hoox.doctor and mix git_hoox.run config-load time. Hooks that do not implement the callback continue to accept arbitrary extras without validation.

Register in .git_hoox.exs:

pre_commit: [
  {MyApp.Hooks.Sobelow, []}
]

The examples/ directory ships ready-to-copy custom hooks (Sobelow, ExCoveralls coverage threshold, JIRA ticket enforcement).

Return values:

ReturnMeaning
:okHook passed, no files modified.
{:ok, modified_paths}Hook passed; runner re-stages paths if stage_fixed: true.
:skipHook deliberately did nothing.
{:error, reason}Hook failed. Commit aborts unless fail_fast: false and other hooks need to run.

Partial Stage and stage_fixed

GitHoox does not stash unstaged changes before running hooks. Hooks see the working tree as-is. This matches lefthook's default behavior and avoids the crash and conflict risks of automatic git stash/git stash pop wrappers.

When a formatter or autofixer mutates a file, set stage_fixed: true on that hook entry to re-git add the modified files automatically. Built-in formatter hooks set this default; opt out per-entry if undesired.

Skipping Hooks

Set the configured skip_env (default GIT_HOOX) at commit time:

GIT_HOOX=0 git commit                       # disable all hooks
GIT_HOOX_EXCLUDE=credo,format git commit    # skip specific hook modules
GIT_HOOX_ONLY=test git push                 # run only one

Module names match the suffix after GitHoox.Hooks. (lowercased).

Hook Output

Hooks stream their combined stdout/stderr to the terminal as it arrives, so a long-running mix dialyzer or mix test shows progress instead of fifteen seconds of silence followed by a single error blob.

Streaming is on by default. To suppress it (for example in scripted contexts where you want only the runner's exit code), set the application env in your config/config.exs:

config :git_hoox, stream_output: false

parallel: true mode buffers each hook's output and flushes it as a single block once the hook finishes, in completion order. Output stays readable — no chunk-level interleaving — but you pay for it in latency: nothing appears on the terminal until the fastest hook completes. If live progress matters more than tidy output, stay on serial dispatch.

Observability

GitHoox emits :telemetry events around every stage and every hook, with no default handler attached. Attach the reference Logger-backed handler with GitHoox.Logger.attach/0, or roll your own — the event shape is documented on GitHoox.Telemetry.

# Reference Logger output.
GitHoox.Logger.attach()

# Or a custom one, e.g. for shipping timings to a metrics backend.
:telemetry.attach(
  "git-hoox-timings",
  [:git_hoox, :hook, :stop],
  fn _ev, %{duration: d}, %{module: mod, result: r}, _ ->
    ms = System.convert_time_unit(d, :native, :millisecond)
    :ok = MyMetrics.observe("git_hoox.hook", ms, mod: mod, result: r)
  end,
  nil
)

Diagnose Setup Issues

mix git_hoox.doctor

Reports the state of the git repo, hooks directory, installed shims, config file, and config validity. Exits non-zero only on hard errors (e.g. malformed .git_hoox.exs); missing shims or missing config surface as [warn] lines so the task is safe to run from CI as a sanity check.

Inspect Resolved Config

mix git_hoox.list

Loads .git_hoox.exs, merges each hook's default_opts/0 with your overrides, and prints the result grouped by stage. Useful for confirming that an opt you set is actually being passed to the hook.

Benchmark Hooks

mix git_hoox.bench                      # pre_commit, 5 runs
mix git_hoox.bench --stage pre_push     # different stage
mix git_hoox.bench --runs 20            # more samples
mix git_hoox.bench -s commit-msg -n 3

Attaches a :telemetry handler, dispatches mix git_hoox.run <stage> the requested number of times, and prints per-hook timing statistics (runs, errors, p50, p95, max, mean, total) sorted by total time. Use it when deciding whether a hook is cheap enough to keep on pre_commit or should move to pre_push.

Uninstall

mix git_hoox.uninstall

Removes only the shims GitHoox installed (identified by a marker comment). Foreign hooks are left untouched. If a .backup.* file exists alongside a removed shim, the most recent backup is restored.

Status

GitHoox is pre-1.0. The public API surface (GitHoox, GitHoox.Hook, GitHoox.Config, GitHoox.Git, GitHoox.Installer, the built-in hook modules, and the mix git_hoox.* tasks) follows semver from 0.1.0 onward, but internals under modules marked @moduledoc false (e.g. config schema) may change without notice.

Pre-releases are cut on demand via the Pre-release GitHub Action (workflow_dispatch) and published to Hex with the standard -rc.N/-beta.N/-alpha.N semver suffix, so you can pin a release candidate with {:git_hoox, "0.2.0-rc.1"} before the stable cut. A rolling -next.N channel is also supported — dispatching the workflow with version 0.3.0-next auto-increments the trailing counter from the existing tags on origin.

Documentation is published to HexDocs.

Changelog

Released versions are recorded in CHANGELOG.md, generated by release-please.

Unreleased changes accumulate in the open Release PR, which release-please refreshes on every push to main and rewrites the upcoming version and CHANGELOG entries into.

License

BSD 2-Clause. See LICENSE.