madoqua
A pre-commit hook for Python repos: format and lint-fix what you staged, re-stage the result, run the read-only checks in parallel, and say one line about it. Small, fast, silent until there’s something to say.
A clean commit prints exactly this and nothing else:
pre-commit ok (3 py files, 1.8s, slowest: ty check 1.6s): ruff fix, ruff format applied & staged; ruff check, ty check passed
A commit with a problem prints only the tool that had one, and stops the commit.
At a glance
madoqua install # write hooks/pre-commit and point git at it
madoqua run # what the hook does; also what a bare `madoqua` does
madoqua stats # what the hook has been costing you
madoqua guide # what to do here, right now
Exit codes
| Code | Meaning |
|---|---|
0 | Clean — the commit may proceed |
1 | A check failed, or the virtualenv guard refused — the commit is blocked |
2 | The run could not complete — madoqua itself is misconfigured or broken |
The three are part of the contract: 1 and 2 are deliberately distinct, so
“your code is not ready” never reads as “madoqua is not working”.
Start at Setup, then Configuration.
Setup
Install
madoqua is a Rust binary shipped as a Python wheel, so any Python installer works:
uv tool install madoqua
# or
pipx install madoqua
# or, per project
uv add --dev madoqua
Wire it into a repository
cd your-python-project
madoqua install
That writes hooks/pre-commit and sets core.hooksPath to hooks. Commit the
shim and everyone who clones the repo gets the same hook — each clone still
runs madoqua install once, since core.hooksPath is local config.
Prerequisites
-
A virtualenv at
<repo_root>/.venvwith the tools the checks call installed. madoqua refuses to run tools from outside it: a systemruffwould lint with a different version than CI, and the failure would look like your code’s fault. -
With the defaults, that means
ruffandty:uv venv && uv sync
Verify
madoqua run # with something staged; prints one line if all is well
madoqua stats # after a few commits
Using it from Claude Code
Paste this into your project’s CLAUDE.md:
## madoqua
`madoqua` is this repo's pre-commit hook. It runs the fixers on the staged
Python files, re-stages them, runs the checks in parallel, and prints one line.
It runs itself on `git commit` — invoke it directly to see what a commit would
say before making one.
```sh
madoqua guide # what to do here, right now
madoqua run # what `git commit` will do; one line if clean
MADOQUA_SKIP="ty check" madoqua run # drop a check by name, this run only
madoqua stats # which check is costing the most time
Start with madoqua guide: it prints setup instructions in a repo that is not
wired up yet, and triage instructions in one that is. madoqua guide tune is
the configuration reference.
Exit codes: 0 clean, 1 a check failed (the commit would be blocked), 2
madoqua could not run. Failing checks print only the failing tool’s output, on
stderr.
Configuration is [tool.madoqua] in pyproject.toml. Set max_output_lines
on a noisy check to keep its failures inside a context window.
<!-- END SHARED:claude-snippet -->
Configuration
madoqua works with no configuration at all. The built-in registry is the one the bash hook it replaces used:
| Phase | Commands |
|---|---|
| fix | ruff check --fix --quiet --, ruff format --quiet -- |
| check | ruff check --quiet --, ty check -- |
[tool.madoqua] in pyproject.toml
[tool.madoqua]
fix = [
"ruff check --fix --quiet",
"ruff format --quiet",
]
check = [
"ruff check --quiet",
{ name = "ty", cmd = "ty check", pass_files = true, timeout_s = 120, max_output_lines = 200 },
]
# log = ".git/hook-timings.jsonl"
An entry is either a command line or a table:
| Key | Default | Meaning |
|---|---|---|
cmd | — | The command line. Required in table form. |
name | the leading non-flag words of cmd | What the verdict, the log and MADOQUA_SKIP call this step. |
pass_files | true | Append the staged file list to the command. false for repo-wide tools. |
timeout_s | none | Kill the step after this long. A killed step fails the commit and is logged with "exit": -1, "timed_out": true. |
max_output_lines | none | Keep this many lines of output and replace the rest with ... (N lines truncated). This is the knob for keeping a failure inside an agent’s context window. |
Command lines are split on whitespace with single and double quotes honoured.
There is no shell: |, ; and && are rejected with an error rather than
being passed along as literal arguments. Put a pipeline in a script and call
the script.
The personal overlay
<repo_root>/.git/hooks.local.toml is the same schema at the top level, and is
not committed — it is where your own preferences go without imposing them on
everyone else:
extend_check = ["bandit -q -r src"]
log = "~/.local/state/madoqua/timings.jsonl"
Four lines of semantics, also in madoqua run --help:
check/fixin the overlay replace that list entirely.extend_check/extend_fixappend to the repo’s list.- Scalar keys (
log, …) — the overlay wins. - A layer that says nothing about a key leaves it alone.
Skipping a check once
MADOQUA_SKIP="ty check" git commit -m "wip"
Comma-separated step names, matched exactly — the built-in registry’s names
are ruff check and ty check, and a table entry’s name overrides that, so
MADOQUA_SKIP="ty" against the defaults silently skips nothing. It filters the
check phase only: skipping the formatter would leave the working tree in a
state the next run reformats anyway.
fix is not skippable, and a fixer madoqua cannot find blocks the commit
rather than being reported as applied — no check reports a missing formatter.
Where timings go
log defaults to .git/hook-timings.jsonl, which is per-repo and disappears
with the clone. Point it at ~/… to collect every repo’s runs in one place —
~ expands, parent directories are created, and records written outside the
repository carry a repo field so stats --repo can tell
them apart.
Writing the log is best-effort. A failure warns on stderr and the commit proceeds.
Limitation: partially staged files
madoqua runs the fixers on the whole file and then git adds it, so a file you
staged in part is committed in full. This is deliberate — the workflows madoqua
is built for (agents, and git add <file>) stage whole files. If you rely on
git add -p, this hook is not for you.
Commands
| Command | What it does |
|---|---|
run | The hook: guard, fix, stage, check, one line. Also what a bare madoqua does. |
install | Write hooks/pre-commit and point git at it. |
stats | Summarise the timing log. |
guide | Print setup, triage or tune instructions for this repository. |
Global flags
| Flag | Meaning |
|---|---|
--root <PATH> | Where to start looking for the repository. Defaults to the current directory. |
--verbose, -v | Raise the default log level to debug. RUST_LOG still wins. |
run
The hook. madoqua with no subcommand does the same thing, so the installed
shim can be a bare exec madoqua.
madoqua run
What it does, in order
- Virtualenv guard.
pythonmust resolve to<repo_root>/.venv/bin/python. If it does not and the venv exists, madoqua puts.venv/binat the front of thePATHits child processes get and setsVIRTUAL_ENV— the observable half ofsource .venv/bin/activate— then checks again. If there is no venv, or activating it does not win, the commit is blocked with instructions. The guard runs before anything else, including the file list: a repo without a venv is misconfigured whether or not this commit touches Python. - Staged files.
git diff --cached --name-only -z --diff-filter=ACMR -- '*.py' '*.pyi'. Nothing staged in Python means exit0with no output and no log entry. - Fix phase. Sequentially, in configuration order. A fixer’s non-zero exit
does not stop the run:
ruff check --fixexits non-zero for what it could not fix, and the check phase is about to report exactly that. A fixer madoqua cannot find is different — nothing downstream reports a missingruff format— so that blocks the commit. git addthe staged file list, so what the fixers wrote is what gets committed.- Check phase. Every check at once, on its own thread, with stdout and stderr captured per check.
- One line, or the failures.
Output
A clean run prints one line to stdout:
pre-commit ok (3 py files, 1.8s, slowest: ty check 1.6s): ruff fix, ruff format applied & staged; ruff check, ty check passed
with (auto-activated .venv) after the parenthesis when the guard had to fix
your PATH — which means your shell is not set up the way you think it is.
A failing run prints nothing to stdout, and to stderr only the tools that failed:
== ty check failed ==
src/a.py:12: error: Argument 1 has incompatible type "int"
Everything informational goes to stderr, so madoqua run stays pipeable.
Configuration
See Configuration, and madoqua run --help for the
overlay semantics in short form.
Exit codes
| Code | When |
|---|---|
0 | Every check passed, or there was nothing staged to check |
1 | A check failed or timed out, a fixer could not be run, or the virtualenv guard refused |
2 | madoqua could not run: not a repository, unreadable config, a command it cannot honour |
install
madoqua install
Writes hooks/pre-commit in the working tree:
#!/bin/sh
root=$(git rev-parse --show-toplevel)
[ -x "$root/.venv/bin/madoqua" ] && exec "$root/.venv/bin/madoqua" run
exec madoqua run
makes it executable, and runs git config core.hooksPath hooks.
Running it twice changes nothing.
Why the working tree and not .git/hooks
hooks/pre-commit can be committed, so everyone who clones the repo gets the
same hook without a setup step. The shim decides where the binary is and
nothing else, for the same reason the bash script it replaces is being
retired: everything past the exec is fixed by upgrading the binary.
Why .venv/bin before PATH
git runs hooks with your login PATH, not your shell’s. madoqua installed as
a dev dependency is on PATH only while the venv is active, and a fish login
shell, an IDE’s git integration or CI never activates it - so a shim that
only said exec madoqua run failed those commits with madoqua: not found.
The virtualenv guard cannot help there: it runs inside a madoqua
that was never found. The shim tries the repo’s own venv first and falls back
to PATH, which is where uv tool install puts it.
Note that core.hooksPath is per-clone git config, so each clone still runs
madoqua install once — or you can set it in your own ~/.gitconfig.
Exit codes
0, unless the run could not complete (2).
stats
madoqua stats
madoqua stats --days 7
madoqua stats --json
madoqua stats --repo myproject # for a log shared between repos
Reads the timing log and prints one row per step:
step n p50 p95 max
ty check 84 1620 2340 4100
ruff check 84 41 58 93
total run 84 1712 2455 4180
Sorted by p95 descending, because p95 is the number worth tuning against: the mean of a check that is usually instant and occasionally twenty seconds describes neither case. All figures are milliseconds, and percentiles are nearest-rank, so every one of them is a duration that actually happened.
The whole-run total is set apart because it is not the sum of the rows above it — checks run in parallel.
Flags
| Flag | Default | Meaning |
|---|---|---|
--days <N> | 30 | How far back to look. |
--json | off | Emit the report as JSON instead of a table. |
--repo <NAME> | none | Only count runs from this repository. Only meaningful when log points outside the repo. |
JSON shape
{
"days": 30,
"runs": 84,
"total": { "n": 84, "p50": 1712, "p95": 2455, "max": 4180 },
"steps": [
{ "name": "ty check", "n": 84, "p50": 1620, "p95": 2340, "max": 4100 }
]
}
repo appears at the top level only when --repo was given. Every field name
is part of the contract — renaming one is a breaking change.
The log itself
One JSON object per run, appended to
log:
{"ts":"2026-08-31T12:03:22Z","head":"a1b2c3d","files":3,"total_ms":1834,
"venv_auto_activated":false,
"steps":[{"name":"ruff fix","phase":"fix","ms":41,"exit":0},
{"name":"ty check","phase":"check","ms":1620,"exit":0}]}
tsis UTC. Local offsets would need a timezone database;Zis unambiguous and sorts, which matters for a log several machines may share.headis the short sha of the parent — the commit being made does not exist yet.exitis the tool’s own code, or-1when madoqua killed it for outliving its timeout, or127when the tool never ran at all. A tool killed by something else — a segfault, an externalkill— is recorded as128 + signal, as a shell would report it, so-1keeps meaning exactly one thing.timed_out: trueappears only on a step madoqua killed, which also carries"exit": -1.repoappears only when the log lives outside the repository.- Runs with no staged Python files are not logged. They would otherwise dominate the percentiles of any repo that also commits prose.
Exit codes
stats is an inventory, not a verdict: 0, or 2 if the run could not
complete. Never 1 — “your checks are slow” is not a finding.
madoqua guide
Print the instructions for one of the three moments someone — usually an agent — meets madoqua.
madoqua guide # let madoqua choose
madoqua guide setup # not wired into this repo yet
madoqua guide triage # a run blocked a commit
madoqua guide tune # the key reference
| Topic | For |
|---|---|
setup | A repository madoqua is not wired into yet. |
triage | A run that exited non-zero. |
tune | The registry, the layers, skipping and the log. |
Choosing a topic
With no topic, madoqua looks at one directory — --root when you pass it, the
current one otherwise — and prints setup when it finds nothing there and
triage when it does. Detection uses that directory as given and never walks
up, so point it at the repository root. What it looks for, in order:
hooks/pre-commitinvoking madoqua — the shiminstallwrites..git/hooks/pre-commitinvoking madoqua — a hand-wired hook.[tool.madoqua]inpyproject.toml, parsed rather than grepped..git/hooks.local.toml, the personal overlay.
tune is never auto-selected: it is a reference, and nothing about a
repository’s state says “you need the reference right now”.
The first line of the output names the topic and why it was chosen, so a reader that passed no topic can tell whether to trust what follows:
# madoqua guide: configured via hooks/pre-commit -> triage
An explicit topic reads nothing from disk and prints
# madoqua guide: tune instead.
Exit codes
guide is an inventory, not a verdict: it returns 0, or 2 if it could not
write its output. It never returns 1 and never needs a git repository.
Where the text comes from
The three pages under Agent guide are include_str!d into
the binary, so the site and the CLI serve the same bytes. Tests hold them to it:
every madoqua command a guide shows is fed through the real argument parser,
every config key it names is checked against what the deserializer accepts, and
no guide may exceed 60 lines — one that grows past a screenful stops being read.
How it works
The pipeline
flowchart TD
guard[venv guard<br/>PATH must reach .venv/bin/python] -->|no venv| blocked[exit 1<br/>with instructions]
guard --> staged[git diff --cached<br/>*.py *.pyi]
staged -->|nothing| quiet[exit 0<br/>no output]
staged --> fix[fix phase<br/>sequential, writes]
fix -->|a fixer that never ran| failures
fix --> add[git add<br/>the same file list]
add --> check[check phase<br/>parallel, read-only]
check --> log[append one JSON line<br/>to the timing log]
log -->|all pass| verdict[one line on stdout]
log -->|any fails| failures[failing tools' output<br/>on stderr, exit 1]
The log is written before the verdict is printed, so a run is timed whether or
not it was allowed through. A fixer that exits non-zero is not a reason to
refuse the commit — ruff check --fix exits non-zero for what it could not
fix, and the check that follows is about to say so — but a fixer that never
ran is: no check reports a missing ruff format, and the commit would record
unformatted code under a verdict claiming it was formatted.
Fix steps run one at a time and in configuration order, because they rewrite the same files and the formatter has to see the linter’s output. Checks are read-only, so they all run at once, each on its own thread with its own capture of stdout and stderr. Each step times itself inside its thread, so what the log records is how long the tool took and not how long it waited to be joined.
Shape of the crate
flowchart LR
main[main.rs<br/>parse, log, exit code] --> cli[cli.rs<br/>command bodies]
cli --> hook[hook.rs<br/>the pipeline, the verdict]
cli --> stats[stats.rs<br/>percentiles, table]
cli --> install[install.rs<br/>the pre-commit shim]
hook --> config[config.rs<br/>what to run]
hook --> venv[venv.rs<br/>PATH]
hook --> git[git.rs<br/>every git call]
hook --> runner[runner.rs<br/>every other process]
hook --> timelog[timelog.rs<br/>the log file]
stats --> report[report.rs<br/>wire format]
timelog --> report
main.rs stays thin: argument parsing, tracing setup, exit-code mapping. Every
command body lives in cli.rs or in the module it delegates to.
Anything that spawns a process or touches the filesystem lives behind a named
seam, and everything downstream of a seam takes already-parsed data. venv.rs
is the clearest case: the guard’s decision is a pure function of the current
PATH and a filesystem oracle, so all four of its branches are unit-tested on
a machine with no virtualenv at all. See
docs/adr/ for the
decisions and what they cost.
Troubleshooting
madoqua: command not found
The wheel installs a binary onto your tool path. With uv tool install, check
that ~/.local/bin is on PATH.
If the hook itself fails this way, remember that git runs hooks/pre-commit
with your login PATH, not your shell’s. The shim madoqua install writes
now looks in <repo>/.venv/bin first, so a dev-dependency install
works without the venv active; a shim written by an older version only says
exec madoqua run; run madoqua install again to replace it. Either way the
binary has to be in the venv for that lookup to find it: uv add --dev madoqua.
madoqua: no virtualenv at …/.venv
madoqua only runs tools out of the repo’s own virtualenv. Create one and install what the checks call:
uv venv && uv sync
The verdict says (auto-activated .venv) every time
That is madoqua telling you your shell has not activated the venv — it worked
around it for the hook’s children, but everything else you type is using a
different python. Activate it, or use uv run.
…/.venv exists but its python is not the one that would run
Something on PATH shadows .venv/bin/python even after madoqua puts it
first — usually a shim from a version manager. command -v python from the
repo root will name it.
A check is slow and I do not know which
madoqua stats
Sorted by p95 descending, so the first row is the one worth fixing. Give that
step a timeout_s while you work on it, or drop it for a single commit with
MADOQUA_SKIP="<name>".
A failing check floods my terminal (or my agent’s context)
Cap it:
check = [{ name = "ty", cmd = "ty check", max_output_lines = 200 }]
The first 200 lines are kept and the rest becomes
... (N lines truncated).
`|` needs a shell, and madoqua runs commands directly
Commands are split with quote-aware whitespace rules and executed directly — there is no shell, so pipes, redirections and expansions are refused rather than passed along as literal arguments. Put the pipeline in a script and call the script.
The output is not what I expect
Run with --verbose (or RUST_LOG=debug) — logs go to stderr, so they never
contaminate stdout:
madoqua --verbose run
cannot parse .../pyproject.toml
madoqua reads [tool.madoqua] out of the project’s pyproject.toml. A missing
file, or a file with no [tool.madoqua] table, is fine — both mean “defaults”.
Malformed TOML is not, and the error names the file. The same goes for
.git/hooks.local.toml.
A commit went through with unformatted code
madoqua only sees git diff --cached. If a file was not staged, it was not
checked. Note also that the fixers run on whole files: a file staged with
git add -p is committed in full.
Setup madoqua in this repository
madoqua is not wired into this repository yet. Run everything below at the repository root, in this order.
1. Install. madoqua runs tools only out of <repo>/.venv, so the hook and
CI lint with the same versions, and the built-in registry runs ruff and ty.
Add all three as dev dependencies:
uv add --dev madoqua ruff ty
(uv tool install madoqua works too, but pins no version per project.)
2. Create the virtualenv, if uv add did not already: uv venv && uv sync.
A missing venv blocks every commit, whether or not it touches Python.
3. Baseline before you gate. Stage your files, run madoqua run, and
resolve everything it reports before installing the hook; run
madoqua guide triage for how. A hook installed into a repo that cannot pass
it gets bypassed with --no-verify, not fixed.
4. Install the hook.
madoqua install
That writes hooks/pre-commit and sets core.hooksPath to hooks. The shim
runs <repo>/.venv/bin/madoqua if it exists, else madoqua from PATH, so
it works from a shell that never activated the venv. Commit the shim and every
clone gets the same hook - each clone still runs madoqua install once,
because core.hooksPath is local config.
5. Configure, only if the defaults are wrong. The built-in registry fixes
with ruff check --fix and ruff format, then checks with ruff check and
ty check. Add to it in pyproject.toml:
[tool.madoqua]
extend_check = ["bandit -q -r src"]
madoqua guide tune is the full key reference, including how a sibling tool
such as biston, zorilla or gerenuk is wired in as one more check.
6. Verify that it runs. madoqua acts only on staged .py and .pyi
files. With none staged it prints nothing, exits 0 and writes no log row, so
silence is not a verdict. Make a one-line edit to any .py file, git add it,
and commit - git runs hooks with the login PATH, not the shell’s, so only a
real commit proves the shim finds the binary. A run that happened ends with
pre-commit ok on stdout or the failing tools’ output on stderr, and
madoqua stats shows its row.
7. Exit codes. 0 clean, 1 the commit is blocked, 2 madoqua could not
run. Keep them distinct in anything that wraps madoqua: 1 is your code’s
problem, 2 is madoqua’s.
next: run madoqua run with a .py change staged
Triage a blocked commit
madoqua blocked a commit, or madoqua run exited non-zero. Work it before you
try to commit again.
Read what it printed. A failing run prints the failing tools’ output on
stderr and nothing else; stdout stays empty. Exit 1 means a check failed or
the virtualenv guard refused - your commit is not ready. Exit 2 means
madoqua could not run at all, and the stderr message names what it could not
do; that is a configuration bug, not a finding about your code.
Apply the remedy ladder. Take the first rule that applies.
no virtualenv at .../.venv: create it and install the tools the checks call -uv venv && uv sync. Nothing about your code is wrong.exists but its python is not the one that would run: something on PATH shadows.venv/bin/python, usually a version-manager shim.command -v pythonat the repository root names it.- A fixer could not start: that tool is not installed in
.venv. Install it. madoqua refuses the commit rather than claim it applied a formatter that never ran. - A check failed: fix what its output names, stage the fix, re-run. The fixers already rewrote and re-staged what they could, so what is left is what no fixer can do for you.
- The check is wrong for this repository - wrong tool, or wrong arguments -
and only then: change it in
[tool.madoqua]and say why in the commit message. Seemadoqua guide tune.
Around every edit. Fix the code, stage it, and run madoqua run again.
Done means one pre-commit ok line on stdout and nothing on stderr.
Do not:
- Do not commit with
--no-verify. That is not passing the hook, that is not running it. - Do not use
MADOQUA_SKIPto get past a check that is telling the truth. It is for a check that is broken or slow right now. - Do not delete a check from
[tool.madoqua]to make one commit go through. That is repo-wide policy, and it hides the same failure for everyone else. - Do not raise
timeout_sormax_output_linesto change a verdict. Neither decides whether a check passes.
next: run madoqua run
Tune madoqua
Reference for what madoqua runs and what it reports. Everything here is repo-wide policy unless it is in the personal overlay.
Layers. Built-in defaults, then [tool.madoqua] in the repo’s
pyproject.toml, then <repo>/.git/hooks.local.toml - the personal overlay,
which is not committed - then MADOQUA_SKIP. Later layers win.
fixandcheckreplace that phase’s list entirely;extend_fixandextend_checkappend to it, after the replacement. Scalar keys such aslog: the later layer wins. A layer silent about a key leaves it alone.
The registry. An entry is a command line, or a table:
[tool.madoqua]
fix = ["ruff check --fix --quiet", "ruff format --quiet"]
check = [
"ruff check --quiet",
{ name = "ty", cmd = "ty check", timeout_s = 120, max_output_lines = 200 },
]
| Key | Default | Meaning |
|---|---|---|
cmd | - | The command line. Required in table form. |
name | leading non-flag words of cmd | What the verdict, the log and MADOQUA_SKIP call this step. |
pass_files | true | Append the staged file list. Set it false for tools that scope themselves. |
timeout_s | none | Kill the step after this many seconds. A killed step fails the commit. |
max_output_lines | none | Keep this many lines of a failure and truncate the rest. This is the knob for fitting a failure into an agent’s context window. |
The file list is the staged .py and .pyi files, relative to the
repository root, appended after the command’s own arguments, so the string
form ruff check --quiet runs as ruff check --quiet a.py pkg/b.py. With no
staged Python file there is no run at all - no step, no log row, exit 0.
Commands are split on whitespace with quotes honoured and run without a
shell, so a pipe, a semicolon or && is refused; put the pipeline in a script.
Siblings, in the same table form; gerenuk computes its own diff:
[tool.madoqua]
extend_check = [
{ name = "biston", cmd = "biston scan --focus-args" },
{ name = "zorilla", cmd = "zorilla check" },
{ name = "gerenuk", cmd = "gerenuk run -- -q", pass_files = false, timeout_s = 120 },
]
Phases. Fix steps run sequentially and are re-staged; a non-zero exit is not fatal, the check that follows reports it, but a fixer that could not start is. Check steps run in parallel, are read-only, and a non-zero exit blocks.
Skipping. MADOQUA_SKIP="ty check" drops checks by exact name for one
run, and never touches the fix phase. log defaults to
.git/hook-timings.jsonl; point it under ~ to pool every repo’s runs, which
madoqua stats --repo=<name> filters. Rows are sorted by p95 descending.
next: run madoqua stats