Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

CodeMeaning
0Clean — the commit may proceed
1A check failed, or the virtualenv guard refused — the commit is blocked
2The 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>/.venv with the tools the checks call installed. madoqua refuses to run tools from outside it: a system ruff would lint with a different version than CI, and the failure would look like your code’s fault.

  • With the defaults, that means ruff and ty:

    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:

PhaseCommands
fixruff check --fix --quiet --, ruff format --quiet --
checkruff 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:

KeyDefaultMeaning
cmdThe command line. Required in table form.
namethe leading non-flag words of cmdWhat the verdict, the log and MADOQUA_SKIP call this step.
pass_filestrueAppend the staged file list to the command. false for repo-wide tools.
timeout_snoneKill the step after this long. A killed step fails the commit and is logged with "exit": -1, "timed_out": true.
max_output_linesnoneKeep 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 / fix in the overlay replace that list entirely.
  • extend_check / extend_fix append 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

CommandWhat it does
runThe hook: guard, fix, stage, check, one line. Also what a bare madoqua does.
installWrite hooks/pre-commit and point git at it.
statsSummarise the timing log.
guidePrint setup, triage or tune instructions for this repository.

Global flags

FlagMeaning
--root <PATH>Where to start looking for the repository. Defaults to the current directory.
--verbose, -vRaise 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

  1. Virtualenv guard. python must resolve to <repo_root>/.venv/bin/python. If it does not and the venv exists, madoqua puts .venv/bin at the front of the PATH its child processes get and sets VIRTUAL_ENV — the observable half of source .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.
  2. Staged files. git diff --cached --name-only -z --diff-filter=ACMR -- '*.py' '*.pyi'. Nothing staged in Python means exit 0 with no output and no log entry.
  3. Fix phase. Sequentially, in configuration order. A fixer’s non-zero exit does not stop the run: ruff check --fix exits 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 missing ruff format — so that blocks the commit.
  4. git add the staged file list, so what the fixers wrote is what gets committed.
  5. Check phase. Every check at once, on its own thread, with stdout and stderr captured per check.
  6. 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

CodeWhen
0Every check passed, or there was nothing staged to check
1A check failed or timed out, a fixer could not be run, or the virtualenv guard refused
2madoqua 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

FlagDefaultMeaning
--days <N>30How far back to look.
--jsonoffEmit the report as JSON instead of a table.
--repo <NAME>noneOnly 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}]}
  • ts is UTC. Local offsets would need a timezone database; Z is unambiguous and sorts, which matters for a log several machines may share.
  • head is the short sha of the parent — the commit being made does not exist yet.
  • exit is the tool’s own code, or -1 when madoqua killed it for outliving its timeout, or 127 when the tool never ran at all. A tool killed by something else — a segfault, an external kill — is recorded as 128 + signal, as a shell would report it, so -1 keeps meaning exactly one thing.
  • timed_out: true appears only on a step madoqua killed, which also carries "exit": -1.
  • repo appears 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
TopicFor
setupA repository madoqua is not wired into yet.
triageA run that exited non-zero.
tuneThe 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:

  1. hooks/pre-commit invoking madoqua — the shim install writes.
  2. .git/hooks/pre-commit invoking madoqua — a hand-wired hook.
  3. [tool.madoqua] in pyproject.toml, parsed rather than grepped.
  4. .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.

  1. no virtualenv at .../.venv: create it and install the tools the checks call - uv venv && uv sync. Nothing about your code is wrong.
  2. 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 python at the repository root names it.
  3. 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.
  4. 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.
  5. 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. See madoqua 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_SKIP to 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_s or max_output_lines to 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.

  • fix and check replace that phase’s list entirely; extend_fix and extend_check append to it, after the replacement. Scalar keys such as log: 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 },
]
KeyDefaultMeaning
cmd-The command line. Required in table form.
nameleading non-flag words of cmdWhat the verdict, the log and MADOQUA_SKIP call this step.
pass_filestrueAppend the staged file list. Set it false for tools that scope themselves.
timeout_snoneKill the step after this many seconds. A killed step fails the commit.
max_output_linesnoneKeep 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