GitHoox
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.1.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.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:
| Variable | Source |
|---|---|
{staged_files} | git diff --cached --name-only --diff-filter=ACMR |
{all_files} | git ls-files |
{push_files} |
refs received on pre_push stdin |
Custom Hooks
Implement the GitHoox.Hook behaviour:
defmodule MyApp.Hooks.Sobelow do
@behaviour GitHoox.Hook
@impl true
def default_opts, do: [files: ~w(lib/**/*.ex), stage_fixed: false]
@impl true
def run([], _opts), do: :ok
def run(files, _opts) do
case System.cmd("mix", ["sobelow", "--exit" | files], stderr_to_stdout: true) do
{_, 0} -> :ok
{out, code} -> {:error, {code, out}}
end
end
end
Register in .git_hoox.exs:
pre_commit: [
{MyApp.Hooks.Sobelow, []}
]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).
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.
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.