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.3.0", only: [:dev], runtime: false}
]
endFetch 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:
| Key | Type | Default | Description |
|---|---|---|---|
hooks | keyword | — | Per-stage list of {Module, opts} entries. |
parallel | boolean | false | Run hooks within a stage concurrently. |
fail_fast | boolean | false | Stop on first failure within a stage. |
skip_env | string | "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.exsDefaults: 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:
| Variable | Source |
|---|---|
{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
endThe 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:
| Return | Meaning |
|---|---|
:ok | Hook passed, no files modified. |
{:ok, modified_paths} | Hook passed; runner re-stages paths if stage_fixed: true. |
:skip | Hook 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: falseparallel: 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.