Run commands like linters and formatters on staged git files.
- Safe. Partially staged files are three-way merged, so a conflicting edit never aborts your commit.
- Universal. A single binary with no runtime - the same tool in Node, Python, Go, Rust or a polyglot monorepo.
- Fast. Written in Rust, it is 5 to 30 times faster than pre-commit, lint-staged, Lefthook and nano-staged.
Running formatters before a commit is easy until you stage part of a file. You add the hunks you want and leave the rest in your working tree - a debug line, a half-finished function. A formatter rewrites the whole file, so its output has to be reconciled with the edits you deliberately held back. Most tools give up and abort the commit or, worse, commit them along with the fix.
stagelint stashes the unstaged edits, runs your commands, then three-way merges the result. What you staged gets formatted, what you did not stays exactly where you left it, and the commit goes through either way.
npm install --save-dev @stagelint/stagelintAdd the hook to your prepare script so it installs itself for the whole team:
{
"scripts": {
"prepare": "stagelint init"
}
}Add it to your project:
uv add --dev stagelintOr install it globally:
uv tool install stagelintpipx install stagelintpython -m pip install --user stagelintDownload a prebuilt binary using cargo-binstall:
cargo binstall stagelintOr compile it from source:
cargo install stagelintbrew install abemedia/tap/stagelintscoop bucket add abemedia https://github.com/abemedia/scoop-bucket
scoop install stagelintmise use aqua:abemedia/stagelintDownload a prebuilt binary or Linux package from the release page.
stagelint initThis creates .git/hooks/pre-commit (or respects core.hooksPath). Use --force to overwrite an
existing hook.
Pass any CLI flag after -- for the hook to run stagelint with:
stagelint init -- --stash trackedIf you already use a hook manager like pre-commit, Lefthook, or husky, call stagelint from your
existing hook configuration instead.
Create .stagelint.yml, .stagelint.yaml, .stagelint.json, .stagelint.jsonc, or
.stagelint.json5 in your project root. The format is a map of glob patterns to commands:
# .stagelint.yml
# String: single command, files appended as args
'*.md': 'prettier --write'
# Object: control whether files are passed
'*.go':
command: 'go vet ./...'
pass_filenames: false
# Array: sequential commands, each a string or an object
'*.ts':
- eslint --fix
- command: 'tsc --noEmit'
pass_filenames: falseMatching files are always appended as arguments unless pass_filenames: false is set. Commands run
from the directory of the config file that declared them, and receive absolute paths.
Negation patterns such as !(*.ts) are not supported, and match nothing rather than failing, so a
task configured with one never runs.
Commands are split using POSIX shell rules on all platforms, so quote any argument containing spaces or backslashes.
Place config files at any level in the repo. Each staged file uses the closest config file walking up toward the root.
Run your linters and formatters over a coding agent's edits, so anything that fails goes back to the agent to fix rather than landing on you at review.
For Claude Code, add this to .claude/settings.json to run it when the agent finishes a turn:
{
"hooks": {
"Stop": [
{
"hooks": [{ "type": "command", "command": "stagelint --unstaged --quiet || exit 2" }]
}
]
}
}The --unstaged flag runs commands against the working tree rather than the index, and --quiet
reduces output to save tokens. Exit code 2 turns a failure into a blocking error, which is what
feeds stderr back to the model.
true runs every task at once, false runs them one at a time, and a number caps how many run
together. Tasks whose globs match the same file are always serialised regardless, in the order the
patterns are declared.
By default the first failing command stops the run and cancels the rest. This runs everything to completion and reports all failures together. The commit is still blocked, and the working tree is still restored.
Runs commands against the files changed in a revision range instead of the staged files. For
example, main...HEAD for everything since your branch diverged, or HEAD~3 for the last three
commits. The commands' changes are staged, as in a normal run.
Runs commands against the files modified in your working tree, including untracked ones, instead of the staged files. Nothing is hidden and nothing is staged: the commands see the working tree as it is and their changes are left there.
Runs commands against the given paths instead of the staged files. A path that no longer exists is
skipped rather than failing the run. Nothing is hidden and nothing is staged, as with --unstaged.
Controls how much of your working tree is hidden while commands run, so they see the content being
committed rather than your work in progress. Each scope includes the previous, and ignored files are
never touched. Rejected with --unstaged and --files, which hide nothing.
partial(default) - Only stash unstaged edits to partially staged files.tracked- Also stash every other dirty tracked file.untracked- Also stash untracked files.
Widen it when a command reads files it was not given - a type-checker or go vet ./... sees your
whole tree, and the default leaves your uncommitted work in place for it to trip over.
Prints only the output of failed commands and errors: no task tree, no warnings. Cannot be combined
with --verbose.
Prints the output of every command and keeps the task tree fully expanded. By default only failed commands have their output shown, so a passing run is just the task tree.
- Identifies staged files and detects partially-staged ones
- Creates a git stash (based on
--stashscope) for crash recovery - Overwrites stashed files with their clean index state
- Runs commands on the real working tree with full project context
- Updates the git index for staged files the commands modified
- Restores stashed files from the stash commit
- Three-way merges the commands' changes into partially-staged files
- Drops the stash ref
If a command fails, the working tree is restored and the commit is blocked. On crash (SIGKILL, power
loss), the stash ref survives - recover with git stash pop.
Paths marked SKIP_WORKTREE - by git sparse-checkout or git update-index --skip-worktree - are
left exactly as staged. No command sees them, and nothing on disk is staged in their place.
Each cell is fully staged / partially staged, measured on a 1,000-file repository with a no-op
task, on a 2019 MacBook Pro (Intel Core i9-9880H).
| Staged files | stagelint | Lefthook | nano-staged | lint-staged | pre-commit |
|---|---|---|---|---|---|
| 10 | 15ms / 30ms | 152ms / 373ms | 224ms / 311ms | 437ms / 530ms | 450ms / 525ms |
| 100 | 19ms / 76ms | 163ms / 537ms | 249ms / 425ms | 455ms / 672ms | 486ms / 612ms |
Partial staging is the expensive path, and the only one where a tool has to hide your unstaged edits
and restore them afterwards. On a commit where prettier takes two seconds this is noise; it matters
on small commits and fast formatters, which is most of them. Reproduce with bench/run.sh.
- lint-staged - Inspired the configuration format and overall workflow.
- git-format-staged - Inspired the concept of formatting staged content and merging it back without blocking commits.
