Formatting on commit keeps the main branch formatted without anybody remembering to run anything. The one rule: format only what is staged, and stage the result.
A git hook, with nothing else
#!/bin/sh
set -eu
tidy up --stagedThat is the whole hook. --staged asks git which files are in the index, formats only those, and stages what it rewrote, so the commit holds the formatted version. A commit that touches no formattable file does nothing and succeeds.
A hook in .git/hooks is not committed, so every clone needs it again. Keep them in the repository instead:
$ mkdir .githooks
$ git config core.hooksPath .githooks
$ chmod +x .githooks/pre-commitcore.hooksPath still has to be set once per clone, so put that line in whatever your project uses to set itself up.
hk
hk runs linters and formatters as git hooks, from one hk.pkl file. It stashes the unstaged work before a commit, so a step only sees what you staged, then stages what the step rewrote and puts the rest back.
There is no tidy builtin, so declare the step yourself:
amends "package://github.com/jdx/hk/releases/download/v2.11.1/hk@2.11.1#/Config.pkl"
steps {
["tidy"] = new Step {
glob = List(
"*.js", "*.jsx", "*.mjs", "*.cjs", "*.ts", "*.tsx", "*.mts", "*.cts",
"*.css", "*.scss", "*.less", "*.html", "*.vue", "*.hbs",
"*.json", "*.jsonc", "*.json5", "*.yaml", "*.yml", "*.toml",
"*.md", "*.markdown", "*.graphql", "*.xml", "*.svg"
)
check = "tidy check {{files}}"
fix = "tidy up {{files}}"
}
}$ hk installgit commit now runs the step, rewrites what it must, and stages the result. The same step answers hk check and hk fix, so CI runs hk check and gets the same thing the hook does.
Do not pass
--stagedinside an hk step. hk already decided which files the step gets, and{{files}}is that list. Asking tidy to go back to git would fight it: a step that is meant to see only part of the index would format all of it.
The glob list is what gives hk a file set per step, which is how it runs steps in parallel. Trim it to the languages your repository actually has.
lint-staged
{
"lint-staged": {
"*": "tidy up"
}
}lint-staged stages what the command rewrote, so nothing else is needed. The * pattern is safe: tidy leaves alone any file whose name it does not recognise.
If tidy is the only thing in your lint-staged config, tidy up --staged does the same job with nothing installed.
With husky:
npx lint-stagedpre-commit
repos:
- repo: local
hooks:
- id: tidy
name: tidy
entry: tidy up
language: system
pass_filenames: truelanguage: system uses the tidy already on the machine, so everyone needs it installed. Until there are prebuilt binaries, that means a cargo build on each developer's machine.
Check instead of write
A hook that rewrites files during a commit can surprise people, because the commit is not quite what they staged. The quieter alternative refuses the commit and leaves the fix to them:
#!/bin/sh
set -eu
if ! tidy check --staged; then
echo
echo "Run: tidy up --staged"
exit 1
fiDo not drop CI
A pre-commit hook is a convenience, not a guarantee: git commit --no-verify skips it, and so does any commit made outside the working copy. Keep the CI check as the thing that actually holds the line.