Markdown accumulates small inconsistencies, trailing spaces, doubled blank lines, a heading with no blank line under it, and they surface in diffs and reviews where nobody wanted to talk about them. The pre-commit framework runs formatters and linters on the staged files before every commit, so they never reach the diff. The setup is ten lines; the part worth a post is what happens on the first commit after adding it.
The configuration
One file at the repository root names the hooks and pins their versions:
repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer
- repo: https://github.com/igorshubovych/markdownlint-cli rev: v0.42.0 hooks: - id: markdownlintpip install pre-commit (or your package manager) installs the framework,
and pre-commit install writes .git/hooks/pre-commit in the clone, which
is what makes the hooks run on git commit.
Two kinds of hook
A hook is a program that receives the staged file names and exits non-zero
to block the commit. The two hooks from pre-commit-hooks are fixers: they
rewrite the file and exit non-zero, so the commit stops and the fix is
already in the working tree. markdownlint, as configured above, is a
checker: it reports and leaves the file alone. The difference shows on the
first run. Take a file with three trailing spaces after “space”, two blank
lines in a row, a heading with no blank line under it and no newline at the
end:
# Notes
Some text with trailing space
## Heading without a blank line afterNext linepre-commit run --all-files runs every hook on every file, which is how a
newly added configuration is tried out:
pre-commit run --all-files# trim trailing whitespace.......................................Failed# - hook id: trailing-whitespace# - exit code: 1# - files were modified by this hook## Fixing docs/README.md## fix end of files...............................................Failed# - hook id: end-of-file-fixer# - exit code: 1# - files were modified by this hook## Fixing docs/README.md## markdownlint...................................................Failed# - hook id: markdownlint# - exit code: 1## docs/README.md:5 MD012/no-multiple-blanks Multiple consecutive blank lines [Expected: 1; Actual: 2]# docs/README.md:6 MD022/blanks-around-headings Headings should be surrounded by blank lines [Expected: 1; Actual: 0; Below] [Context: "## Heading without a blank line after"]All three failed, for two different reasons. The fixers failed because they
changed the file: git status now shows AM docs/README.md, the staged
copy still carrying the whitespace and the working copy without it. Staging
again and re-running is the whole remedy for a fixer. The checker failed
because the file has two problems it will not touch, and re-running reports
them again:
git add docs/README.mdpre-commit run --all-files# trim trailing whitespace.......................................Passed# fix end of files...............................................Passed# markdownlint...................................................Failed# - hook id: markdownlint# - exit code: 1## docs/README.md:5 MD012/no-multiple-blanks Multiple consecutive blank lines [Expected: 1; Actual: 2]# docs/README.md:6 MD022/blanks-around-headings Headings should be surrounded by blank lines [Expected: 1; Actual: 0; Below] [Context: "## Heading without a blank line after"]Both of those rules are ones markdownlint can fix, and one argument turns the checker into a fixer:
- id: markdownlint args: [--fix]pre-commit run --all-files# trim trailing whitespace.......................................Passed# fix end of files...............................................Passed# markdownlint...................................................Failed# - hook id: markdownlint# - files were modified by this hook
git add docs/README.mdpre-commit run --all-files# trim trailing whitespace.......................................Passed# fix end of files...............................................Passed# markdownlint...................................................PassedSo the first commit after adding hooks fails on purpose, once per fixer that found something, and the second one goes through. Which hooks to make fixers is a choice: a formatter should fix, because nobody wants to hand-apply its opinion; a rule that needs a judgement call should report, so the author makes it.
Start with two hooks
The configuration above is a good first set: it changes nothing anyone disagrees with. A linter with a hundred rules on day one produces a hundred-line report on the first commit and gets disabled by lunchtime. Add a rule when a review comment shows the need for it.
Limitations
The hooks run only in a clone where pre-commit install has been run, and
git commit --no-verify skips them. The same pre-commit run --all-files
in continuous integration is what makes them a rule rather than a
convention, and its environment setup wants ~/.cache/pre-commit cached
between runs.
The rev pins go stale. pre-commit autoupdate moves them to the latest
tags; Renovate can open the pull request.
The first --all-files run on an existing repository rewrites every file the
fixers disagree with. That is one large formatting commit, best made on its
own before anyone else’s branch has to rebase over it.
Comments