Cactus

Package VersionHex Docsmitgleam jsgleam erlang

A tool for managing git lifecycle hooks with ✨ gleam! Pre commit, Pre push and more!

logo

Install

gleam add --dev cactus

Javascript

Bun, Deno & Nodejs are all supported!

πŸŽ₯ Obligatory VHS

demo

▢️ Usage

FIRST configure hooks in gleam.toml, then initialize them:

# Erlang target
gleam run --target erlang -m cactus
# JavaScript target (pick one runtime)
gleam run --target javascript --runtime nodejs -m cactus
gleam run --target javascript --runtime bun -m cactus
gleam run --target javascript --runtime deno -m cactus

The --target and --runtime flags you pass here are baked into the generated hook scripts under .git/hooks/. Use the same target/runtime your project builds with, since hooks invoke gleam run -m cactus -- <hook-name>.

CLI commands

CommandDescription
init (default)Initialize hooks for the current OS (gleam vs gleam.exe)
unix-initForce Unix-style hook scripts
windows-initForce Windows-style hook scripts (gleam.exe)
helpShow usage
cleanRemove cactus-generated hook scripts from .git/hooks/
<hook-name>Run a hook's actions (e.g. pre-commit)

Pass global flags before the command:

gleam run -m cactus -- --verbose --dry-run init
gleam run -m cactus -- --config path/to/gleam.toml init

Config

Settings that can be added to your project's gleam.toml:

[cactus]
# Re-initialize hooks on every hook run (default: false)
always_init = false
[cactus.pre-commit]
# Default files_scope for all actions in this hook (default: "all")
files_scope = "staged"
# stop on first failure (default) or run all actions and fail at end
on_failure = "stop"
# Skip the entire hook when CI=true (see "skip_env" below)
skip_env = "CI=true"
actions = [
# command: required β€” binary path, gleam module, or gleam subcommand name
# kind: "module" (default), "sub_command", or "binary"
# args: extra arguments (default: [])
# files: paths/extensions/globs that trigger the action (default: [] = always run)
# files_scope: "staged" | "all" | "unstaged" β€” overrides hook default
# cwd: working directory for the action (default: project root)
# skip_env: skip when NAME=value β€” see "skip_env" below
# env: { KEY = "value" } β€” extra environment variables
{ command = "format", kind = "sub_command", args = ["--check"], files = [".gleam"], files_scope = "staged" },
{ command = "./scripts/test.sh", kind = "binary" },
{ command = "go_over", kind = "module" },
]

files filter

An action runs when any watched pattern matches any file in the chosen files_scope:

An empty files list means the action always runs.

files_scope

ValueGit commands used
stagedgit diff --cached --name-only
unstagedgit diff --name-only + untracked files
allunion of staged and unstaged (default when unset)

For pre-commit hooks, files_scope = "staged" is recommended so linters only run when relevant staged files change.

skip_env

Skip a hook or individual action when an environment variable equals a specific value. Useful when CI runs the same checks separately and you do not want hooks to duplicate work (or fail) in the pipeline.

Syntax β€” NAME=value (only the first = separates name from value, so the value may contain =):

[cactus.pre-push]
skip_env = "CI=true" # skip every action when CI=true
actions = [
{ command = "./scripts/test.sh", kind = "binary" },
{
command = "format",
kind = "sub_command",
args = ["--check"],
skip_env = "SKIP_HOOKS=1",
},
]

Matching β€” the env var must match exactly (case-sensitive). Unset vars never match.

Example skip_envSkips when…
CI=trueCI is set to true
SKIP_HOOKS=1SKIP_HOOKS is set to 1
CI=1CI is set to 1 (not true)

Most CI providers set CI=true. Use that unless your pipeline uses a different value.

Hook vs action β€” a hook-level skip_env applies to every action in that hook. An action without its own skip_env inherits the hook default. Set skip_env on a single action to skip only that step.

Use --verbose to see when hooks or actions are skipped.

cwd

When cwd is set on an action, the command runs in that directory. File filtering (files / files_scope) only considers paths under that directory (relative to the repository root). Use this for monorepo packages:

{ command = "gleam test", kind = "binary", cwd = "packages/foo", files = [".gleam"], files_scope = "staged" }

Pre-commit stash behavior

The pre-commit and pre-merge-commit hooks stash unstaged and untracked changes before running actions, then restore them afterward. This keeps formatters/linters from seeing dirty working-tree state.

Supported hooks

Client-side (typical local use):

applypatch-msg, commit-msg, post-checkout, post-commit, post-merge, post-rewrite, pre-applypatch, pre-auto-gc, pre-commit, pre-merge-commit, prepare-commit-msg, pre-push, pre-rebase, test

Server-side (remote/git server β€” rarely needed locally):

fsmonitor-watchman, post-update, pre-receive, push-to-checkout, update

Windows

Git hook scripts are shell scripts (#!/bin/sh). On Windows they require Git Bash or another sh-compatible environment bundled with Git for Windows. Native cmd/PowerShell hooks are not generated.

Use windows-init or let init detect the platform to write gleam.exe in hook scripts.

Troubleshooting

ProblemFix
Hooks not runningRun gleam run -m cactus from project root; ensure .git/hooks/<name> exists and is executable
Wrong gleam/runtime in hookRe-run init with correct --target and --runtime; choices are embedded in hook scripts
Action skipped unexpectedlyCheck files, files_scope, and skip_env; use --verbose
Stash pop conflict after pre-commitRun git stash list, resolve conflicts, git stash drop the cactus-pre-commit entry if needed
Not in a git repoInitialize git first: git init
--config path not foundPass absolute or relative path to a valid gleam.toml
Stash skipped warning during hookAn existing git stash blocked cactus from stashing; commit or stash manually so actions see a clean tree
Stash not restored after pre-commitCheck git stash list; cactus errors if the top stash is not cactus-pre-commit