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 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 check # every check on the whole repo; fixes and stages nothing
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 check # every check on the whole repo; fixes nothing
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 = "build/timings.jsonl" # default: hook-timings.jsonl in the git dir
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. |
on | ["*.py", "*.pyi"] | Globs for the staged files that wake this step. A list replaces the default rather than adding to it. |
pass_files | true | Append the staged files on matched to the command. false for repo-wide tools. |
timeout_s | none | Kill the step after this long. A killed step fails the run, unless it is blocking = false, 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. |
when | "always" | Checks only. "commit" runs it only in the hook, "full" only in madoqua check. |
full_cmd | cmd | Checks only. What madoqua check runs instead of cmd, handed no file list. |
blocking | true | Checks only. false never fails the run. A failure is reported on stderr and as failed (advisory) in the verdict; output from a passing run is shown on stderr too, as passed with output (advisory) — for warn-only tools that always exit 0. |
when, full_cmd and blocking on a fixer are refused with an error: fixers
run only in the hook, and a fixer’s failure never blocks anyway. So is
full_cmd on a step with when = "commit" or "full": one of its two
commands would never run.
Which files wake a step: on
A step runs only when at least one staged file matches its on list, and with
pass_files it is handed exactly those files. A step that matches nothing is
not run, not logged and not named in the verdict; a commit in which no step
matches anything prints nothing and writes no log row.
The globs follow .gitignore, minus everything but the wildcards:
| Glob | Matches |
|---|---|
*.py | No /: a file name at any depth — a.py, pkg/sub/a.py. |
uv.lock | Likewise any uv.lock; write /uv.lock for the root one only. |
tests/*.py | Has a /: anchored at the root, and * stays in one directory. |
tests/** | ** as a whole segment spans any depth: everything under tests. |
? is one character. Nothing else is special. A trailing / and an empty
list are refused rather than read as “never”.
This is how a test runner that works out its own diff is woken by more than Python, without being handed files it would not know what to do with:
[tool.madoqua]
extend_check = [
{ name = "gerenuk", cmd = "gerenuk run -- -q", pass_files = false,
on = ["*.py", "uv.lock", "pyproject.toml", "tests/**"], timeout_s = 120 },
]
In madoqua check the same globs are matched against
every file in the repository instead of the staged ones.
Only the files a fixer ran on are re-staged. A file that only a check looked at keeps whatever you staged of it.
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 hook and the whole-repo run
madoqua run (the hook) and madoqua check (the whole repository) run from
the same list. A check runs the same command in both unless it says otherwise:
[tool.madoqua]
extend_check = [
# The staged files at commit, the whole tree in `madoqua check`.
{ name = "biston", cmd = "biston scan --focus-args", full_cmd = "biston scan ." },
# Too slow for every commit.
{ name = "pytest", cmd = "pytest -q", pass_files = false, when = "full" },
# Worth reading, not worth failing over.
{ name = "pysentry", cmd = "pysentry-rs .", pass_files = false, when = "full",
blocking = false },
]
The personal overlay
hooks.local.toml in the repository’s git directory 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.
Worktrees share one git directory
The overlay and the default timing log live in the repository’s git directory:
<repo_root>/.git in an ordinary clone, and the clone’s .git in a linked
worktree, where <worktree>/.git is a file pointing at it. madoqua asks git
(git rev-parse --git-common-dir) instead of joining .git/ onto the working
tree, so one overlay configures every worktree of a repository and one log
holds every worktree’s runs.
That is deliberate, and it is what makes an overlay useful in the
worktree-per-task workflow: a per-checkout overlay would have to be written
again for every task branch and would vanish with git worktree remove.
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 hook-timings.jsonl in the git directory, which is per-repo
and disappears with the clone. A configured path is relative to the working
tree instead — that is where build/timings.jsonl belongs. 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. |
check | The whole repository: every check on every file, nothing fixed or staged. |
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. Every child process gets
<repo_root>/.venv/binat the front of itsPATHandVIRTUAL_ENVpointing at that venv — the observable half ofsource .venv/bin/activate. That happens on every run, not only when the venv is missing fromPATH: wherepythonresolves says nothing about wherepytestdoes, and a directory in front of the venv that holds one but not the other would otherwise decide what a check, or a process a check spawns by name, actually runs. The rest ofPATHis kept, in order, behind it.pythonthen has to resolve to<repo_root>/.venv/bin/python: if there is no venv, or something still shadows it, 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, then each step keeps the files itsonglobs match —*.pyand*.pyiunless it says otherwise. A step that matches nothing does not run, and is neither logged nor named in the verdict. If no step matches anything, the exit is0with no output and no log entry. - Fix phase. Sequentially, in configuration order, each fixer on its own
matched files. 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 files a fixer ran on, so what the fixers wrote is what gets committed. A file only a check looked at is not re-added: that would stage edits you deliberately left out.- Check phase. Every matched check at once, on its own thread, with
stdout and stderr captured per check. Checks with
when = "full"are left tocheck. - One line, or the failures.
Output
A clean run prints one line to stdout:
pre-commit ok (3 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 .venv/bin was not
already the front of your PATH and the guard put it there — 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"
A check with blocking = false never blocks the commit. If it fails, its
output goes to stderr under == name failed (advisory) ==, and the verdict
ends name failed (advisory) instead of counting it as passed. If it passes
but prints something — a warn-only tool such as a prose linter exits 0 by
design — that output goes to stderr under == name (advisory) ==, and the
verdict says name passed with output (advisory), so a reader of stdout alone
knows there is something to read:
pre-commit ok (2 files, 0.4s, slowest: ty check 0.3s): ruff check, ty check passed; prose-lint passed with output (advisory)
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 blocking check passed (a failed advisory check is reported), or no step matched anything staged |
1 | A blocking 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 |
check
The whole repository, checked the way the hook checks a commit.
madoqua check
This is the command for “is the repo clean” — before a push, in CI, or in place
of a hand-written task that runs the same tools. It runs from the same
[tool.madoqua] list as the hook, so the two cannot drift apart.
What it does, in order
- Virtualenv guard, exactly as
rundoes it. - Every file a commit could contain. Tracked files, plus untracked ones
git does not ignore, minus files deleted from the working tree, sorted by
path. Each check keeps the files its
onglobs match, as it does with the staged files in the hook. - Check phase. Every check whose
whenincludes the full run, at once, in parallel. A check with afull_cmdruns that instead of itscmd, with no file list. - One line, or the failures.
There is no fix phase and nothing is staged: this run reports, it never rewrites. Nothing is written to the timing log either — the log is for the hook’s latency, and a full test run in it would swamp the percentiles.
One list, two runs
[tool.madoqua]
check = [
"ruff check --quiet",
"ty check",
{ name = "biston", cmd = "biston scan --focus-args", full_cmd = "biston scan ." },
{ name = "gerenuk", cmd = "gerenuk run -- -q", pass_files = false, when = "commit",
on = ["*.py", "uv.lock", "pyproject.toml"] },
{ name = "pytest", cmd = "pytest -q", pass_files = false, when = "full" },
{ name = "vulture", cmd = "vulture src", pass_files = false, when = "full" },
{ name = "pysentry", cmd = "pysentry-rs .", pass_files = false, when = "full",
blocking = false },
]
ruff checkandty checkrun in both: at commit on the staged Python files, here on every Python file in the repository.bistonscans what is staged at commit and the whole tree here.gerenukworks out its own diff, so it only makes sense at commit;pytestruns the whole suite instead, and only here.pysentryis advisory: its findings are printed, and the run still passes.
See Configuration for every key.
Output
A clean run prints one line to stdout:
check ok (148 files, 6.2s, slowest: pytest 5.9s): ruff check, ty check, biston, pytest, vulture passed; pysentry failed (advisory)
with (auto-activated .venv) after the parenthesis when the guard put
.venv/bin on PATH. A run in which no check matched a file still says so:
check ok (0 files, 0.0s): no checks run.
A failing run prints nothing to stdout, and to stderr the checks that failed — every one of them, since all checks run to the end. A failed advisory check is marked as one:
== ty check failed ==
src/a.py:12: error: Argument 1 has incompatible type "int"
== pysentry failed (advisory) ==
CVE-2026-0001 in somelib 1.2.3
A failed advisory check prints to stderr on a clean run too, and so does an
advisory check that passed but printed something, under == name (advisory) ==
and named in the verdict as name passed with output (advisory).
Exit codes
| Code | When |
|---|---|
0 | Every blocking check passed, or no check matched a file |
1 | A blocking check failed, timed out or could not be run, or the virtualenv guard refused |
2 | madoqua could not run: not a repository, unreadable config, a command it cannot honour |
Large repositories
Without a full_cmd, a check that takes files is handed every matching file
in the repository on its command line. On a repository big enough to exceed
the system’s argument limit, the tool cannot be started and the check fails
with exit 127, naming it. Give that check a full_cmd that lets the tool
find the files itself — ruff check . instead of a file list.
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.filescounts the staged files at least one step ran on.- Runs in which no step’s
onmatched a staged file 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, or printed advisory output. |
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/>.venv/bin first on PATH, VIRTUAL_ENV set] -->|no venv| blocked[exit 1<br/>with instructions]
guard --> staged[git diff --cached<br/>matched per step by its on globs]
staged -->|no step matched| 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 files the fixers ran on]
add --> check[check phase<br/>parallel, read-only]
check --> log[append one JSON line<br/>to the timing log]
log -->|no blocking check failed| verdict[one line on stdout]
log -->|a blocking check failed| failures[failing tools' output<br/>on stderr, exit 1]
A check with blocking = false never takes the second branch: if it fails,
or passes but prints something, its output goes to stderr marked (advisory),
and the verdict still prints and names it.
The whole-repo run
madoqua check is the same pipeline with the commit-specific parts taken
out:
flowchart TD
guard[venv guard] -->|no venv| blocked[exit 1<br/>with instructions]
guard --> files[git ls-files<br/>tracked + untracked, not ignored, on disk]
files --> check[check phase<br/>when and full_cmd applied]
check -->|no blocking check failed| verdict[one line on stdout]
check -->|a blocking check failed| failures[failing tools' output<br/>on stderr, exit 1]
No fix phase, no git add, no log row. Which checks run, and with which
command, is decided in config.rs before the pipeline starts
(Config::for_mode), so neither hook.rs nor the runner has a branch on
which run it is.
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/>both pipelines, 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 .venv/bin was not the front of your PATH — it
worked around that for the hook’s children, but everything else you type is
using a different python. Activate the venv, or use uv run.
It says the same thing when the venv is on PATH but something sits in front
of it: a version manager’s shim directory has no python of its own, so
python still resolves into the venv while pytest comes from somewhere else.
A check ran a tool from outside the venv
It should not be able to: .venv/bin goes to the front of the PATH every
tool inherits, and VIRTUAL_ENV names the repo’s venv, so a tool that a check
spawns by name resolves there too. Older versions only rewrote PATH when
python itself resolved elsewhere, which left a half-activated shell free to
shadow everything else. If you still see it, check that the tool is installed
in the venv at all — .venv/bin/<tool> --version.
…/.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
hooks.local.toml.
Does it work in a git worktree?
Yes, and it uses the same overlay and the same timing log as the clone the
worktree belongs to: a linked worktree’s .git is a file pointing at the
clone’s metadata directory, and madoqua asks git where that is rather than
joining .git/ onto the checkout. A log you configure yourself stays
relative to the working tree you are committing in.
madoqua 0.2.2 and earlier joined the path literally, so every run in a linked
worktree failed with cannot read …/.git/hooks.local.toml: Not a directory (os error 20) and exit 2, whether or not an overlay existed. Upgrade rather
than working around it: the tools madoqua runs are the ones guarding the
commit.
My tests’ git changed the real repository
A test suite that runs real git in a temporary directory — git init in
tmp_path, then git config, git commit, git push — can end up operating
on the repository you are committing to. git exports GIT_DIR and
GIT_INDEX_FILE (and sometimes GIT_WORK_TREE, GIT_PREFIX, -c settings)
to every hook, and a git that inherits GIT_DIR ignores the directory it is
run in. From a linked worktree the damage is worst: core.bare = true and a
user.name written into the clone’s config, branches replaced, junk commits
pushed.
madoqua 0.2.4 and later remove those variables — everything
git rev-parse --local-env-vars lists, plus GIT_NAMESPACE and
GIT_CEILING_DIRECTORIES — from the environment of every tool it runs, and
runs each tool from the repository root. GIT_AUTHOR_*, GIT_COMMITTER_* and
GIT_SSH* are left alone. madoqua’s own git calls still see the hook’s
environment, since they are about the repository being committed to.
On 0.2.3 and earlier, upgrade. If you have been bitten, check
git config --local --list for core.bare and user.* entries you did not
set, and git reflog for branches that moved. A test suite that must also run
under other hook runners can protect itself by deleting those variables in a
fixture before it touches git.
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. By default 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 but
advisory output.
Advisory output (== name (advisory) ==, or (advisory) in the verdict)
never blocks. Act on it if it is about what you just wrote - it is aimed at
you - but it is not a blocker, and the commit is not waiting on it.
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, [tool.madoqua] in pyproject.toml, the
uncommitted overlay hooks.local.toml in the git directory (<repo>/.git, or
the clone’s in a linked worktree), 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 with these keys:
| 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. |
on | ["*.py", "*.pyi"] | Globs for the staged files that wake this step. Replaces the default. |
pass_files | true | Append the files on matched. Set it false for tools that scope themselves. |
timeout_s | none | Kill the step after this many seconds. Killed is failed. |
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. |
when | always | Checks only. commit: only in the hook; full: only in madoqua check. |
full_cmd | cmd | Checks only. What madoqua check runs instead, with no file list. |
blocking | true | Checks only. false: never fails the run; its output is shown even on a pass. |
on and the file list. A step runs only if a staged file matches its
on, and gets those files after its arguments: ruff check --quiet a.py.
Globs as in .gitignore: no / matches the name at any depth, * stays in one
directory, ** spans many. With no match anywhere there is no run at all - no
output, no log row, exit 0. No shell: |, ; and && are refused.
The whole repo. madoqua check runs every check as if every file were
staged (tracked, or untracked and not ignored), and fixes, stages and logs
nothing. The siblings, set up for both runs:
[tool.madoqua]
extend_check = [
{ name = "biston", cmd = "biston scan --focus-args", full_cmd = "biston scan ." },
{ name = "zorilla", cmd = "zorilla check" },
{ name = "gerenuk", cmd = "gerenuk run -- -q", pass_files = false, when = "commit",
on = ["*.py", "uv.lock", "pyproject.toml"], timeout_s = 120 },
{ name = "pytest", cmd = "pytest -q", pass_files = false, when = "full" },
{ name = "pysentry", cmd = "pysentry-rs .", pass_files = false, when = "full",
blocking = false },
]
Phases. Fixers run in order, only their files are re-staged, and only a fixer that could not start blocks. Checks run in parallel; non-zero blocks.
Skipping. MADOQUA_SKIP="ty check" drops checks by exact name for one
run, and never touches the fix phase. log defaults to hook-timings.jsonl in
the git directory; 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