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.
poly hooks installThat 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 hooks
Section titled “Builtin hooks”| 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 |
[hooks]stages = ["pre-commit", "commit-msg"]
[hooks.builtin]lint = truefmt = truecommit = { stages = ["commit-msg"] }file_safety = truecargo = trueThe 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.rsFix mode, scoped runs and priority
Section titled “Fix mode, scoped runs and priority”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.
Git hook arguments
Section titled “Git hook arguments”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>.
Nested poly.toml
Section titled “Nested poly.toml”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/excludeglobs are relative to that directory (a glob starting with*matches at any depth); a jobglobis otherwise matched against the repository-relative path and*crosses/; - hook ids are prefixed with the directory (
svc:ruff-format), andpoly hooks installbinds 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.
Inline jobs
Section titled “Inline jobs”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"Per-file vs whole-workspace hooks
Section titled “Per-file vs whole-workspace hooks”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 appendedfiles = "packages/python/**/*.py" # gate: only run when a Python file is stagedworkspace = trueA workspace = true job takes no appended filenames — use a {staged_files} template to opt back
in. The cargo builtin group is whole-workspace automatically.
Staged isolation
Section titled “Staged isolation”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 snapshotThe 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 }.
Letting a build read an untracked file
Section titled “Letting a build read an untracked file”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.
Hook concurrency: serial
Section titled “Hook concurrency: serial”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 = trueserial = "cargo" # never beside another member of the "cargo" setserial 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.
Prerequisites: precondition and before
Section titled “Prerequisites: precondition and before”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 = trueprecondition = "test -f gradlew" # not applicable here -> visible skip, not a failurebefore = "./gradlew --version" # setup broke -> THIS hook's verdict is unknownThe 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 ✓ rustConditional skip / only
Section titled “Conditional skip / only”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.
Hook timeouts
Section titled “Hook timeouts”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 timeoutFour 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 caching
Section titled “Hook caching”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 = truecache = { 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.
Hook catalogs
Section titled “Hook catalogs”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.
[[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:
version = 1
[[hooks]]id = "ai-rulez-validate"stages = ["pre-commit"]args = ["generate", "--dry-run"]files = [".ai-rulez/**", "**/.ai-rulez/**"]workspace = truepass_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:
[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.
Skipping hooks
Section titled “Skipping hooks”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.
Hook exit codes
Section titled “Hook exit codes”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 |
The whole-project lint phase
Section titled “The whole-project lint phase”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 phaseThis is the per-group counterpart to [workspace] run = "never", which disables the whole-project
phase for every tool at once.
The phase executes tools
Section titled “The phase executes tools”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 alwaysopts back in. Under--workspace alwaysthe tools still see the whole repository, regardless of the named paths and of[discovery] exclude; poly prints a note saying so. [discovery] excludedoes 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.
Applying whole-project fixes
Section titled “Applying whole-project fixes”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.