Skip to content

Git hooks

poly hooks is poly’s own git-hook runner, configured entirely from poly.toml [hooks]. There is no external hook framework in between — no .pre-commit-config.yaml, no Python environment to bootstrap, no separate version to keep in sync.

Terminal window
poly hooks install

That wires the git shims once; the configured stages then run on every git commit.

A hook that already exists is not lost: it is kept as <hook>.legacy and the poly shim runs it before poly, with the same arguments and stdin; if it fails, poly does not run. A lefthook hook is the exception. poly replaces lefthook, and running both would run every check twice, so it is kept (for poly hooks uninstall to restore) but never run, and install says so. Pass --replace-lefthook to discard it instead.

By default only a failing job’s output is printed. Set show_output = true on a job, or run poly hooks run -v, to see the output of jobs that passed too.

Builtin Runs
lint poly lint over the staged files
fmt poly fmt over the staged files
commit Conventional Commit and AI-trailer check on the commit message (gitfluff)
file_safety Pure-Rust checks: merge-conflict markers, added large files, private keys, case conflicts, and shebang/executable parity
cargo Whole-workspace cargo clippy, cargo sort, cargo machete and cargo deny — each PATH-probed and skipped when absent
poly.toml
[hooks]
stages = ["pre-commit", "commit-msg"]
[hooks.builtin]
lint = true
fmt = true
commit = { stages = ["commit-msg"] }
file_safety = true
cargo = true

The three file-scoped builtins (lint, fmt, file_safety) inherit [discovery] exclude — a repo’s excluded paths are stated once, not restated per hook. A hook’s own exclude adds to the inherited globs; exclude_mode = "replace" in the hook’s table opts out and keeps only its own:

[hooks.builtin.lint]
exclude = ["**/tags.rs"] # effective: [discovery] exclude + **/tags.rs

lint and fmt only check by default. fix = true makes them rewrite and re-stage what they change (poly lint --fix, poly fmt --fix); fix-mode builtins never overlap each other. priority orders hooks (lower runs first, equal priorities run in parallel).

A builtin that must run over different files with different excludes takes jobs; each entry becomes its own hook and inherits whatever it does not set from the builtin:

[hooks.builtin]
fmt = { fix = true, jobs = [
{ files = "*.py", exclude = "gen/**" },
{ files = "*.rb", priority = 1 },
] }
file_safety = { aws_credentials = true, jobs = [
{ checks = ["private_key", "aws_credentials"] },
{ checks = ["added_large_files"], exclude = "demo/data/**" },
] }

aws_credentials is off unless named: it rejects files containing an AWS secret found in the environment or the AWS credentials files, and passes when none are configured.

requirements_txt_fixer is also off unless named. It sorts requirements files the way the pre-commit-hooks hook of that name does (by lower-cased package name, comments staying with the requirement below them, a top comment block staying on top, pkg-resources==0.0.0 removed) and is re-staged like any other fixer. Scope it with files = "*requirements*.txt"; poly migrate does.

A run line may use {0} / {git_args} (all arguments git passed to the hook), {1}, {2}, … (one argument). Jobs in stages without a file list (post-checkout, post-merge, …) and prepare-commit-msg also receive the arguments as $@, and every job sees POLY_GIT_ARG_COUNT and POLY_GIT_ARG_<n>.

Git runs hooks from the repository root, where poly reads the root poly.toml. A [hooks] table in a poly.toml below the root runs on commit too, confined to its directory:

  • it applies only to files under that directory, and contributes nothing when none is staged;
  • its hooks run with that directory as their working directory and receive file arguments relative to it (as a root = "<dir>" job does);
  • its files / exclude globs are relative to that directory (a glob starting with * matches at any depth); a job glob is otherwise matched against the repository-relative path and * crosses /;
  • hook ids are prefixed with the directory (svc:ruff-format), and poly hooks install binds the git hooks a nested table uses.

Only the nested file’s own [hooks] applies: the root’s hooks are not inherited, and a nested table’s isolate, snapshot, sources and stage-level precondition / before / after are not honoured, because they belong to the run as a whole. A nested table needs a file-list stage (pre-commit, pre-push, …); stages without files (post-checkout, …) run the root’s hooks only. poly hooks run started in a subdirectory loads that directory’s cascaded config instead of adding nested tables. poly migrate --recurse writes a nested directory’s hooks to its poly.toml and deletes the nested source when nothing was lost.

Add a job for anything else — it wraps an existing script or task target, no plugin needed:

[hooks.pre-commit.scripts.docs]
script = "scripts/check-docs.sh"
runner = "bash"
files = "**/*.md"

Most hooks are per-file: they receive the staged file list and run on it. But some tools analyze the whole project at once — cargo clippy, a type checker like pyrefly, mypy or tsc — and cannot be scoped to a file list. Mark those workspace = true:

[hooks.pre-commit.commands.pyrefly]
run = "pyrefly check packages/python" # whole-package; no staged files appended
files = "packages/python/**/*.py" # gate: only run when a Python file is staged
workspace = true

A workspace = true job takes no appended filenames — use a {staged_files} template to opt back in. The cargo builtin group is whole-workspace automatically.

Every hook in a commit-gating run — per-file and whole-workspace alike — validates one tree: a non-destructive snapshot of the git index, not the live worktree. Unlike git stash-based approaches, your working tree is never touched.

Isolation is on by default for the commit-gating stages (pre-commit, pre-merge-commit) and skipped for --all-files and non-index stages, which check the worktree by design. Opt out for the whole run with:

[hooks]
isolate = false # validate the live worktree instead of the staged snapshot

The snapshot holds as much of the index as the run needs. When every job that will run is per-file, snapshot = "auto" (the default) writes only the staged files, the tracked files beside them and in every directory above them (config files, __init__.py, sibling modules), and any tracked path a job’s command line names. A one-file commit then costs a handful of files instead of the whole tree, and lives in a separate staged-sparse directory. Every other directory appears in the snapshot as a symlink to the same path in your working tree, so existence probes (ruff’s isort deciding that armis.common.lib is first-party, a config found by walking up) answer as they would in the full tree; nothing a run validates is read through a link. A ruff config that extends a file elsewhere in the repository (extend = "../shared/ruff-base.toml") has that file selected too, and read from the index. A hook in a sparse run must not write through a link: poly compares the working tree under every linked directory before and after the run and fails it, naming the directories, when they differ (use snapshot = "full" for a hook that writes outside its files). A workspace = true or cargo job that has files to run on needs the whole tree and gets the full snapshot as before; snapshot = "full" forces it. Every selected file is still read from the index, so a partially staged file is validated as staged.

A job that needs the untracked node_modules cannot find it in the snapshot. poly doctor and poly hooks run warn about this; link it in with snapshot_include = ["node_modules"] or set isolate = false.

The snapshot is a persistent cache in the per-user cache directory (<platform-cache>/poly/<repo-key>/staged, outside the repo), refreshed in place each run. Content is sourced straight from the git index blob — never copied from the worktree — so an unstaged edit can never leak in regardless of git’s stat-cache state. A file is re-materialized only when its staged object id changed since the last snapshot, tracked by a path-to-OID manifest, so unchanged files are left untouched and cargo / pyrefly / tsc incremental caches stay warm. Files that left the tree are pruned while tool caches inside the snapshot are preserved. It self-heals and is purgeable like any cache (poly cache clean).

A sparse-checkout worktree is supported. An entry omitted from the live checkout (git’s skip-worktree bit, set by git sparse-checkout or git update-index --skip-worktree) is still materialized into the snapshot — the checkout passes --ignore-skip-worktree-bits — and the live checkout is never expanded, so the omitted file does not appear in your working tree. A sparse snapshot = "auto" run writes every such entry too, since there is no working-tree file to link to; the first run pays for them once and later runs only re-check them. This needs git 2.6 or newer.

The snapshot is not itself a git checkout, so a hook running there is handed the repository’s git context: GIT_DIR is the absolute git dir (.git/worktrees/<name> in a linked worktree) and GIT_INDEX_FILE the absolute path of the index the commit is building — the alternate index of a git commit <paths> or git commit -a included. A plain git ls-files or git cat-file therefore reads the commit’s index, and git diff / git status compare the snapshot against it. GIT_WORK_TREE is never set (an inherited one is removed), so a snapshot-local git init cannot re-point your checkout, and GIT_OPTIONAL_LOCKS=0 stops git status from rewriting the index’s stat cache. A hook that creates scratch repositories must clear GIT_DIR and GIT_INDEX_FILE, as it already must in a linked worktree, where git exports both. A hook’s own env overrides any of these.

A stage_fixed hook that rewrites its matched files writes into the snapshot, so the fix is carried back to the worktree copy — but only where that copy is byte-identical to the index. Where it differs, the author holds unstaged work the write-back must not silently overwrite or stage; the fix is withheld instead, and for a stage_fixed hook that fails the run rather than losing the unstaged edit. The failure names the files and the commands that resolve it (git add them, or git stash push --keep-index). To have poly stage just the fixed content and leave your unstaged edits in the worktree, set partial_stage = "restage" under [hooks].

A stage_fixed job running poly fmt --fix needs nothing extra: a successful --fix exits 0. (--exit-nonzero-on-fix is the opposite, for a CI step that must fail on an unformatted tree.) The fmt and lint builtins write the right command line for you with fmt = { fix = true }.

Because the snapshot holds exactly the staged content, a workspace = true hook whose build reads something git does not track — a generated file, a gitignored local config a type checker loads, a downloaded fixture directory — fails under the commit gate while passing in the worktree, and the tool’s error names the missing file rather than the isolation that removed it.

Name those paths, one at a time:

[hooks]
snapshot_include = ["config/local.json", ".env.build", "vendor/fixtures"]

Entries are repository-relative; an absolute path or one containing .. is rejected when the config loads. An entry that simply does not exist is skipped with a warning, never a hard failure — the whole point is a file git does not track. poly hooks prints a note naming the entries on every run, green ones included, because an allowlist is a deliberate exception to “these are the bytes a commit would capture” and a reader who does not know it is in force cannot judge what the run proved.

Hooks in a stage run concurrently on poly’s rayon pool — whole-project hooks included, since overlapping cargo clippy with tsc is where a run’s wall-clock is won. serial is the opt-out for a job that cannot tolerate a peer running at the same time:

[hooks.pre-commit.commands.migrate]
run = "./bin/migrate --check"
serial = true # never beside another `serial = true` job
[hooks.pre-commit.commands.tests]
run = "cargo test --workspace"
workspace = true
serial = "cargo" # never beside another member of the "cargo" set

serial names a mutual-exclusion set, not a stop-the-world: a serial job still runs alongside every hook outside its set. serial = true joins the shared set, serial = "<name>" joins a named one, and serial = false opts out of both — overriding a stage-level parallel = false.

The built-in cargo group ships in the "cargo" set already; nothing to configure. Cargo serializes its own subcommands on the package-cache lock, and anything that builds on the build-directory lock, so running clippy / sort / machete / deny at once buys no wall-clock and costs the queue its visibility: a blocked subcommand prints nothing while its own timeout budget runs down. That is how a cargo deny check which takes 1.7s alone gets killed at the 30-minute whole-project budget.

A job whose run line invokes cargo joins the set automatically; a script that shells out to cargo is invisible to poly and should name serial = "cargo" itself.

A hook can declare what must be true before it runs. Both keys exist at stage scope ([hooks.<stage>]) and, preferably, at hook scope:

[hooks.pre-commit.commands.kotlin]
run = "./gradlew detekt"
workspace = true
precondition = "test -f gradlew" # not applicable here -> visible skip, not a failure
before = "./gradlew --version" # setup broke -> THIS hook's verdict is unknown

The two mean different things:

key on failure scope of the damage
precondition hook skipped — it does not apply here not a failure
before hook’s verdict is unknown — it did not run fails the run

Scope also decides which tree the prerequisite is evaluated against. A hook-scoped prerequisite runs in the hook’s own execution root — the staged snapshot for a workspace = true hook under isolation, the worktree otherwise. Stage-scoped steps are not tied to a hook and always run in the worktree.

A hook that does not run is always listed in the report with its reason — never silently dropped:

[stage] pre-commit
- kotlin-not-applicable (precondition not met: test -f settings.gradle)
? kotlin-snippets (not run — setup failed in ~/.cache/poly/<key>/staged: ./gradlew --version)
before: ./gradlew --version
ERROR: Gradle wrapper jar missing
✓ rust

skip and only accept a bare boolean or a list of { run = "<command>" } conditions; a condition is active when its command exits 0.

[hooks.pre-commit.commands.kotlin]
run = "./gradlew detekt"
only = [{ run = "test -f settings.gradle" }]

Only the run form is evaluated. Other lefthook condition forms (ref = "...", bare git-operation names like "merge") are rejected at config load rather than accepted and ignored — a guard that silently does nothing is worse than no guard.

Every process a run spawns — hook bodies, before / after steps, and precondition probes — runs under a time budget. A wedged tool is killed (the whole process group: SIGTERM, then SIGKILL) and reported as killed, which is deliberately not the same as failed.

Defaults are hang detectors, not performance budgets:

Shape Default budget
Per-file hook 10 minutes
workspace = true hook 30 minutes (a cold cargo clippy is legitimately slow)
before / after step 10 minutes
precondition probe 60 seconds

A hook still running after 15 seconds announces itself on stderr, then every minute, naming the hook and its kill deadline.

Set a per-job budget with timeout — whole seconds, or a duration (500ms, 30s, 10m, 1h), or 0 / off / none to run it unbounded:

[hooks.pre-commit.commands.ai-rulez-validate]
run = "ai-rulez validate"
timeout = "90s" # this tool is known to wedge; bound it tightly
[stage] pre-commit — validated worktree
⧖ ai-rulez-validate (timed out: poly killed it after 90.2s, limit 90.0s)
markers: ✓ passed × failed ⧖ killed by poly on timeout

Four environment variables override the budgets run-wide, taking the same values: POLY_HOOK_TIMEOUT, POLY_HOOK_WORKSPACE_TIMEOUT, POLY_HOOK_STEP_TIMEOUT and POLY_HOOK_PRECONDITION_TIMEOUT. Resolution is environment override → timeout in poly.toml → shape default. Disabling restores the previous behaviour exactly: no deadline, no liveness notice, no separate process group.

A cargo hook gets one extra protection, and it needs no configuration. Cargo serializes on $CARGO_HOME/.package-cache, so a hook can sit blocked behind rust-analyzer or your own cargo build without doing any work — and be killed for waiting. Before starting a hook in the cargo exclusion set, poly checks that lock and, if somebody outside the run holds it, waits for it to clear before the hook’s clock starts. The wait is bounded by half the hook’s own budget (a hook with timeouts disabled never waits), and when that runs out the hook is started anyway rather than withheld.

Hook results are cached ([cache.results] hooks = "safe" by default): a hook is skipped entirely when its declared inputs are unchanged since the last passing run. The cargo group is keyed on the Rust source and manifest set out of the box, so a commit touching no Rust skips clippy / sort / machete / deny. Opt out with cargo = { cache = false }.

Give a custom whole-workspace job the same treatment by declaring its inputs:

[hooks.pre-commit.commands.pyrefly]
run = "pyrefly check packages/python"
files = "packages/python/**/*.py"
workspace = true
cache = { inputs = ["packages/python/**/*.py", "pyproject.toml"] }

For workspace hooks the cache key is derived from staged content, so it stays correct under isolation. For Rust compile times, enable [cache.sccache] to content-cache rustc output.

Hooks can also come from local or Git producer catalogs. Git refs are resolved into poly.lock; normal runs stay on the locked commit, and poly lock --upgrade refreshes configured branches or tags.

poly.toml — the consumer
[[hooks.sources]]
id = "ai-rulez"
git = "https://github.com/acme/poly-hooks.git"
revision = "v4.9.0"
hooks = ["ai-rulez-validate"]
[[hooks.sources]]
id = "ai-rulez-dev"
path = "../ai-rulez"
hooks = ["ai-rulez-validate"]

Exactly one of git or path is required. Git sources require a revision and a committed lock; local sources accept relative, parent-relative or absolute paths, remain unlocked, and reload on every run. The hooks list explicitly selects producer hook IDs, so new producer hooks never become active without a consumer configuration change.

The producer alone owns poly-hooks.toml. It can publish multiple hooks and multiple guarded execution paths for each hook:

poly-hooks.toml — the producer
version = 1
[[hooks]]
id = "ai-rulez-validate"
stages = ["pre-commit"]
args = ["generate", "--dry-run"]
files = [".ai-rulez/**", "**/.ai-rulez/**"]
workspace = true
pass_filenames = false
[[hooks.paths]]
channel = "pnpm"
check = "command -v pnpm"
run = "pnpm dlx ai-rulez@latest"
[[hooks.paths]]
channel = "uvx"
check = "command -v uvx"
install = "uv tool install ai-rulez"
run = "uvx ai-rulez"

Every hook requires at least one path. poly checks paths in the machine preference order and uses the first whose check exits zero. An optional install command runs only during explicit poly hooks install; ordinary hook runs use run directly, allowing commands such as pnpm dlx or uvx to self-provision. poly does not fall through if installation or the selected command fails.

Machine-only preferences belong in gitignored poly.local.toml:

poly.local.toml
[hook_preferences]
channels = ["pnpm", "uvx", "system"]

Because that file is gitignored it never exists in a freshly created linked worktree, so poly also looks for it beside the main worktree — the nearest file wins.

Git catalogs share a global cache under $XDG_CACHE_HOME/poly/hook-sources (or the platform cache directory). poly keeps one URL-keyed bare mirror and immutable checkouts keyed by commit. A per-source lock serializes fetch and materialization, so different repositories can safely use the same catalog concurrently without duplicate clones. Catalog hooks always execute from the consumer repository; the read-only producer checkout is available through POLY_HOOK_SOURCE_ROOT. Local path sources bypass the global cache.

To skip every poly hook for one invocation, set POLY_SKIP_HOOKS=1. This is the supported escape hatch, since git commit --no-verify bypasses pre-commit and commit-msg but never prepare-commit-msg.

A prepare-commit-msg run that cannot provision its external hook sources warns and continues rather than blocking the commit; every other stage still treats that failure as fatal.

poly hooks run distinguishes three outcomes, so a CI job reading only the exit status can tell a clean run from one that checked nothing:

exit meaning
0 validated and clean (a hook with no matching files counts as validated)
1 a hook failed, or a before left a hook’s verdict unknown
2 validated nothing — a precondition withheld every configured hook

poly lint runs the cargo group as its whole-project phase. To keep it as a pre-commit gate but skip it in poly lint — a CI validate job whose plain checkout cannot compile the workspace, while a dedicated job runs clippy — set lint = false:

[hooks.builtin.cargo]
lint = false # runs in git hooks, excluded from `poly lint`'s whole-project phase

This is the per-group counterpart to [workspace] run = "never", which disables the whole-project phase for every tool at once.

poly lint without --fix applies no fixes of its own: poly’s per-file tier only writes under --fix, and the whole-project phase is asked for check mode. But that phase executes the configured tools against the live worktree, and those tools are ordinary programs whose own side effects poly neither requests nor controls — cargo clippy populates target/ and can refresh Cargo.lock, a go invocation can append to go.work.sum, a type checker can write its own cache. So a plain poly lint can leave the tree changed, even though poly itself changed nothing.

Three consequences worth knowing:

  • The phase is not path-scoped. poly lint <paths> skips it entirely for that reason; --workspace always opts back in. Under --workspace always the tools still see the whole repository, regardless of the named paths and of [discovery] exclude; poly prints a note saying so.
  • [discovery] exclude does not apply to it. That key filters poly’s own file discovery, not what a whole-project tool chooses to read.
  • Opting out makes the run fully read-only. Pass --workspace never, or set [workspace] run = "never". Use one of them for a checkout that must stay pristine — a CI job that diffs the tree afterwards, or a gate on a read-only source tree.

Under --fix, the whole-project phase runs its tools in fix mode: cargo sort sorts in place, cargo-machete --fix prunes unused dependencies, and cargo clippy --fix --allow-dirty --allow-staged applies clippy autofixes (cargo deny has no autofix and stays check-only).

Fix mode is what --fix adds; the phase itself runs either way, so --workspace never — not the absence of --fix — is what skips it. poly fmt is a pure formatter and never runs the whole-project phase, since that phase is linting, not formatting. The git-hook and commit-gate path never requests fix mode, so a commit is never silently auto-rewritten by it.