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

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

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 = "build/timings.jsonl"   # default: hook-timings.jsonl in the git dir

An entry is either a command line or a table:

KeyDefaultMeaning
cmd—The command line. Required in table form.
namethe leading non-flag words of cmdWhat 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_filestrueAppend the staged files on matched to the command. false for repo-wide tools.
timeout_snoneKill 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_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.
when"always"Checks only. "commit" runs it only in the hook, "full" only in madoqua check.
full_cmdcmdChecks only. What madoqua check runs instead of cmd, handed no file list.
blockingtrueChecks 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:

GlobMatches
*.pyNo /: a file name at any depth — a.py, pkg/sub/a.py.
uv.lockLikewise any uv.lock; write /uv.lock for the root one only.
tests/*.pyHas 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 / 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.

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

CommandWhat it does
runThe hook: guard, fix, stage, check, one line. Also what a bare madoqua does.
checkThe whole repository: every check on every file, nothing fixed or staged.
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. Every child process gets <repo_root>/.venv/bin at the front of its PATH and VIRTUAL_ENV pointing at that venv — the observable half of source .venv/bin/activate. That happens on every run, not only when the venv is missing from PATH: where python resolves says nothing about where pytest does, 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 of PATH is kept, in order, behind it. python then 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.
  2. Staged files. git diff --cached --name-only -z --diff-filter=ACMR, then each step keeps the files its on globs match — *.py and *.pyi unless 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 is 0 with no output and no log entry.
  3. 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 --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 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.
  5. Check phase. Every matched check at once, on its own thread, with stdout and stderr captured per check. Checks with when = "full" are left to check.
  6. 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

CodeWhen
0Every blocking check passed (a failed advisory check is reported), or no step matched anything staged
1A blocking 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

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

  1. Virtualenv guard, exactly as run does it.
  2. 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 on globs match, as it does with the staged files in the hook.
  3. Check phase. Every check whose when includes the full run, at once, in parallel. A check with a full_cmd runs that instead of its cmd, with no file list.
  4. 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 check and ty check run in both: at commit on the staged Python files, here on every Python file in the repository.
  • biston scans what is staged at commit and the whole tree here.
  • gerenuk works out its own diff, so it only makes sense at commit; pytest runs the whole suite instead, and only here.
  • pysentry is 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

CodeWhen
0Every blocking check passed, or no check matched a file
1A blocking check failed, timed out or could not be run, or the virtualenv guard refused
2madoqua 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

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.
  • files counts the staged files at least one step ran on.
  • Runs in which no step’s on matched 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
TopicFor
setupA repository madoqua is not wired into yet.
triageA run that exited non-zero, or printed advisory output.
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/>.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.

  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 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_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, [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.

  • 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 with these keys:

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.
on["*.py", "*.pyi"]Globs for the staged files that wake this step. Replaces the default.
pass_filestrueAppend the files on matched. Set it false for tools that scope themselves.
timeout_snoneKill the step after this many seconds. Killed is failed.
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.
whenalwaysChecks only. commit: only in the hook; full: only in madoqua check.
full_cmdcmdChecks only. What madoqua check runs instead, with no file list.
blockingtrueChecks 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