Skip to main content

README.md

# 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](https://github.com/evilmartians/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`:

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

Fetch and install the git hook shims:

```sh
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.

```sh
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.

```elixir
# .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.

```elixir
{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.

```elixir
{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:

```elixir
{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`.

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

### `GitHoox.Hooks.Shell`

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

```elixir
{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:

```elixir
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`:

```elixir
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:

```sh
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

```sh
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](https://hexdocs.pm/git_hoox).

## Changelog

Released versions are recorded in [CHANGELOG.md](CHANGELOG.md), generated by
[release-please](https://github.com/googleapis/release-please).

Unreleased changes accumulate in the open
[Release PR](https://github.com/sgerrand/git_hoox/pulls?q=is%3Apr+is%3Aopen+label%3A%22autorelease%3A+pending%22),
which release-please refreshes on every push to `main` and rewrites the
upcoming version and CHANGELOG entries into.

## License

BSD 2-Clause. See [LICENSE](LICENSE).