Lefthook for a Static Site: Four Hooks That Earn Their Keep
A static Astro site doesn't need Husky-grade ceremony. But it does have four failure modes worth catching before they reach `main`: a broken type, a leaked key, a post without a `directory:` field, and a build that silently breaks production. Here's the lefthook.yml I landed on, and why each hook is there.
For most of this site’s life I had no git hooks at all.
That was fine until it wasn’t. Three things happened in the same month:
- I shipped a post that routed to
/the-slug/instead of/technical-notes/the-slug/because I forgot one line of frontmatter, and only noticed when a friend asked why the canonical URL looked wrong. - I pushed a commit where
astro checkwould have caught a typed prop mismatch, butpnpm buildhappened to succeed locally on a cached run. - I almost — almost — committed a
.envwith a real ElevenLabs key in it.
Each of those was a five-second mistake. None of them needed a CI pipeline to catch. They needed something between me typing git commit and the commit landing. That’s what git hooks are for, and that’s what I’d been quietly avoiding for two years because the only tool I knew was Husky and Husky always felt like a lot of ceremony for a personal site.
Then I tried lefthook.
What lefthook is, in one paragraph
Lefthook is a git hooks manager written in Go by Evil Martians. You declare your hooks in a single lefthook.yml at the repo root, install once, and from then on every git commit and git push runs the commands you listed — in parallel, scoped to globs you choose, against staged files. There’s no Node dependency (it’s a single binary you can install with mise use -g lefthook or brew install lefthook), no .husky/ folder full of shell scripts, no package.json “prepare” dance. The config is the documentation.
I’d describe its appeal as: the smallest config that still does the obvious right thing.
The four failure modes worth a hook
Before writing any YAML I sat down and listed what could plausibly go wrong on this repo between “edit a file” and “push to main.” A static Astro 6.3 site, deployed as plain HTML, has a small surface area — most of the categories you’d hook on a Rails or Django app simply don’t apply. What’s left:
- A broken TypeScript prop or import. Astro’s editor integration catches most of this, but I sometimes commit from a terminal session where the LSP isn’t running.
astro checkis the source of truth. - A secret in a file I didn’t mean to stage.
.env,credentials.json, anything underpodcast/(which holds API-key-shaped strings for ElevenLabs and Resemble), the data modules undersrc/data/where I once typo-pasted a token into a “site” config. - A post without a
directory:frontmatter field. This is the only mistake on the list that’s specific to this repo. The content schema treatsdirectoryas optional, but the URL builder uses it to construct/<category>/<slug>/. Forget it and the post quietly routes to/<slug>/instead, breaking the URL shape that the rest of the site (and Google’s index) expects. - A build that fails on a clean checkout but succeeds in my warm dev server. Rare, but the consequence is that
mainhas a broken build for however long it takes me to notice. A pre-pushpnpm buildis the cheapest possible insurance.
Four hooks. That’s the whole list.
The file
pre-commit:
parallel: true
commands:
astro-check:
glob: "src/**/*.{astro,ts,tsx,mts}"
run: pnpm exec astro check
gitleaks:
glob: "{.env*,**/credentials*,**/*.pem,**/*.key,src/data/**,podcast/**}"
run: gitleaks detect --staged --no-banner --redact --verbose
post-frontmatter:
glob: "src/content/posts/**/*.{md,mdx}"
run: |
fail=0
for f in {staged_files}; do
if ! rg -q '^directory:\s*\S' "$f"; then
echo "✗ $f is missing a non-empty 'directory:' frontmatter field."
echo " Without it the post routes to /<slug>/ instead of /<category>/<slug>/."
fail=1
fi
done
exit $fail
pre-push:
commands:
build:
run: pnpm buildThree things I want to call out, because they’re the parts I’d have gotten wrong if I’d written this in a hurry:
glob: is doing real work. Without it, astro check would run on every commit no matter what changed — slow enough that I’d start using --no-verify within a week, which defeats the entire point. With the glob, the hook is skipped on commits that touch only markdown, only images, or only lefthook.yml itself. A hook you keep is worth more than a hook you bypass.
parallel: true matters more than it looks. astro check and gitleaks are independent — they read different files and don’t share state. Running them serially adds two to three seconds to every commit; in parallel they finish in the time of the slower one. On a personal site that latency is the difference between the hook fades into the background and the hook becomes the thing I curse at.
The frontmatter check is repo-specific, and that’s fine. A general-purpose linter wouldn’t know about this site’s URL convention. A hook can — it’s allowed to encode the one rule I keep forgetting. The body is just rg (ripgrep) against each staged post, looking for a non-empty directory: line. It’s not a markdown parser. It’s not robust to comments-inside-frontmatter or YAML edge cases. It’s six lines of shell that catch the exact mistake I made in October.
What I deliberately left out
The temptation, every time I sit down to write a config like this, is to add hooks “while I’m here.” I want to flag a few things I considered and didn’t add:
prettier --checkon staged files. I format on save in the editor. A hook would catch nothing and slow down every commit. If I were on a team I’d add it; solo, it’s noise.- A spell-checker on blog posts. Tried it. Too many false positives on names, code identifiers, and Nepali words. The signal/noise wasn’t worth the time it cost me to read the output.
- Commit-message linting (Conventional Commits). I don’t release this site; the changelog is
git log. Forcingfeat:andfix:prefixes on a personal repo is cosplay. - Running the full Pagefind index in
pre-push. Pagefind runs as part ofpnpm buildalready. Re-running it would double the push latency for no extra coverage.
The pattern, written out: add a hook only when you can point at a specific past mistake it would have caught. Everything else is theater.
Installing it
# pick one
mise use -g lefthook
brew install lefthook
# then, in the repo:
pnpm add -D lefthook
pnpm exec lefthook installlefthook install writes the actual .git/hooks/pre-commit and pre-push files that delegate to lefthook. From then on, every clone of the repo only needs pnpm install && pnpm exec lefthook install to be wired up. You can dry-run a hook without committing — pnpm exec lefthook run pre-commit — which is the single most useful command for debugging the config without making a junk commit.
The part where I admit it’s small
This is a static personal site. The blast radius of a bad commit is “I push again in three minutes.” Nothing here is load-bearing the way a lefthook.yml in a payments codebase is load-bearing.
But the point of these four hooks isn’t to defend against catastrophe. It’s to stop a class of mistake I was making every few weeks, with no upstream signal until it was already on the live site. Lefthook turned out to be the right shape for that: small enough that the config fits on one screen, fast enough that I haven’t reached for --no-verify once, scoped tightly enough that 80% of my commits skip 80% of the hooks.
I should have done this two years ago. The reason I didn’t was that the last time I’d set up git hooks was with Husky in 2019, and I remembered it as a chore. Lefthook isn’t a chore. It’s a config file.
That’s the whole post.
Keep reading
Structured extraction with Pydantic + Claude: guests, topics, and quotes from raw transcripts
Schema-first prompting with Pydantic + Anthropic tool use, a Haiku triage pass that gates Sonnet extraction, prompt caching for the system block, and a single retry that feeds the validation error back into the prompt.
`uv tool` and Single-File Scripts: pipx and Shebang-Python, Replaced
Two uv features outside project management — uv tool for global CLIs and PEP 723 inline-deps for single-file scripts — quietly close out the last reasons to reach for pip or pipx.
Starting a new Python project in 2026 with uv
Six commands, five files, zero `source activate`. A walkthrough from `uv init` to a project that runs tests, ships a CLI, and is ready to dockerize.
What did you take away?
Thoughts, pushback, or a story of your own? Drop a reply below — I read every one.
Comments are powered by Disqus. By posting you agree to their terms.