davideisinger.com

My personal website
Log | Files | Refs | README

commit 988a5c21767c58880e6cbdb7d5b6e1e3c657e6a6
parent ecc839244e71e9cdca0ef567073642552572a30e
Author: David Eisinger <[email protected]>
Date:   Sat, 10 Oct 2026 15:32:44 -0400

Spellcheck pre-commit hook

Diffstat:
M.dictionary | 2--
A.githooks/pre-commit | 5+++++
M.githooks/pre-push | 3+--
MREADME.md | 16++++++++++++++--
Mbin/check-dispatch-hook | 12+++---------
Abin/check-spelling-hook | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Abin/check-staged-spelling | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 236 insertions(+), 15 deletions(-)

diff --git a/.dictionary b/.dictionary @@ -109,12 +109,10 @@ monkeypatch moto mysqldump neckbeard -Need to install the following packages: nerding newegg nums offgrid -Ok to proceed? (y) jammies omfg onesie organisations diff --git a/.githooks/pre-commit b/.githooks/pre-commit @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" +exec bin/check-staged-spelling diff --git a/.githooks/pre-push b/.githooks/pre-push @@ -2,5 +2,4 @@ set -euo pipefail cd "$(git rev-parse --show-toplevel)" -bin/check-dispatch-dates "$@" -exec npx cspell 'content/**/*.md' +exec bin/check-dispatch-dates "$@" diff --git a/README.md b/README.md @@ -33,8 +33,18 @@ Enable the repository's Git hooks once per clone: git config core.hooksPath .githooks ``` -Every push also runs `npx cspell 'content/**/*.md'` against the working tree and -blocks on spelling errors. This requires Node.js and npm (`npx`). +Before each commit, `bin/check-staged-spelling` runs `npx cspell` on the staged +contents of added, changed, or renamed `.md` and `.markdown` files throughout the +repository. Unstaged edits and deleted files are skipped. This requires Python 3, +Node.js, and npm (`npx`). + +Unknown words are added to `.dictionary`, which is sorted and deduplicated. If it +changes, the commit is blocked so you can review `git diff -- .dictionary`. +Remove actual typos from the dictionary and fix them in the Markdown; keep valid +exceptions. Stage the corrected files and `.dictionary`, then retry the commit. +The hook also blocks while `.dictionary` differs from its staged version, so +retrying without reviewing and staging it does not silently accept new words. +No files are automatically staged. Commits without staged Markdown skip spellcheck. Pushing to `origin`'s `main` checks newly added, non-draft Dispatches and existing Dispatches changing from draft to published. Their `date` must be no more than @@ -58,6 +68,8 @@ it does not enforce dates on server-side merges or pushes from other clones. Run `bin/check-dispatch-hook` to exercise the workflow with temporary local Git repositories, without contacting the publishing remote. +Run `bin/check-spelling-hook` to test the commit hook offline in a disposable +repository with a simulated spellchecker. --- diff --git a/bin/check-dispatch-hook b/bin/check-dispatch-hook @@ -16,17 +16,14 @@ with tempfile.TemporaryDirectory(prefix="dispatch-hook-") as directory: root = Path(directory) work = root / "work" work.mkdir() - # Keep these hook tests offline while verifying cspell invocation and failure. + # Pushing must no longer invoke spellcheck. commands = root / "commands" commands.mkdir() npx = commands / "npx" npx.write_text('#!/usr/bin/env python3\n' 'import os, sys\n' - 'assert sys.argv[1:] == ["cspell", "content/**/*.md"]\n' - 'assert os.path.samefile(os.getcwd(), os.environ["HOOK_TEST_WORK"])\n' - 'if os.environ.get("HOOK_TEST_SPELL_FAIL"):\n' - ' sys.stderr.write("Spelling errors found\\n")\n' - ' sys.exit(1)\n') + 'sys.stderr.write("Unexpected npx invocation during push\\n")\n' + 'sys.exit(1)\n') npx.chmod(0o755) env.update(PATH=f"{commands}{os.pathsep}{env['PATH']}", HOOK_TEST_WORK=str(work)) @@ -79,9 +76,6 @@ with tempfile.TemporaryDirectory(prefix="dispatch-hook-") as directory: # Old posts remain editable and renameable without changing their dates. published.write_text(published.read_text() + "Correction.\n") commit() - env["HOOK_TEST_SPELL_FAIL"] = "1" - push(False, message="Spelling errors found") - del env["HOOK_TEST_SPELL_FAIL"] push(True) git("mv", "content/journal/dispatch-published", "content/journal/dispatch-renamed") commit() diff --git a/bin/check-spelling-hook b/bin/check-spelling-hook @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Test real commits offline, substituting a predictable spellchecker.""" + +from pathlib import Path +import os +import shutil +import subprocess +import tempfile + +repo = Path(__file__).resolve().parent.parent +env = dict(os.environ, GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull) + +with tempfile.TemporaryDirectory(prefix="spelling-hook-") as directory: + root = Path(directory) + work = root / "work" + work.mkdir() + commands = root / "commands" + commands.mkdir() + log = root / "calls" + npx = commands / "npx" + npx.write_text('''#!/usr/bin/env python3 +import os, sys +from pathlib import Path +assert sys.argv[1:-1] == ['--yes', 'cspell', '--words-only', '--unique', '--no-progress', + '--no-summary', '--config', '.cspell.json'] +assert sys.argv[-1].startswith('stdin://') +with open(os.environ['SPELL_LOG'], 'a') as log: + log.write(sys.argv[-1] + '\\n') +if os.environ.get('SPELL_FAIL'): + print('simulated tool failure', file=sys.stderr) + sys.exit(1) +if os.environ.get('SPELL_NOISE'): + print('Need to install the following packages:') + print('Ok to proceed? (y) jammies') + sys.exit(int(os.environ['SPELL_NOISE'])) +known = set(Path('.dictionary').read_text().splitlines()) +issues = False +for word in sys.stdin.read().split(): + if word.startswith('unknown') and word not in known: + print(word) + issues = True +sys.exit(1 if issues else 0) +''') + npx.chmod(0o755) + env.update(PATH=f"{commands}{os.pathsep}{env['PATH']}", SPELL_LOG=str(log)) + + def git(*args, ok=True): + result = subprocess.run(["git", "-C", str(work), *args], env=env, + capture_output=True, text=True) + assert (result.returncode == 0) == ok, result.stderr + return result + + def commit(ok=True): + return git("commit", "-m", "Test change", ok=ok) + + git("init", "-b", "main") + git("config", "user.name", "Hook Test") + git("config", "user.email", "[email protected]") + git("config", "commit.gpgsign", "false") + for name in (".githooks/pre-commit", "bin/check-staged-spelling"): + target = work / name + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(repo / name, target) + dictionary = work / ".dictionary" + dictionary.write_text("existing\n") + (work / ".cspell.json").write_text("{}") + git("add", ".") + commit() + git("config", "core.hooksPath", ".githooks") + + # Paths with spaces, duplicate words, and a partially staged file. + post = work / "post with spaces.md" + post.write_text("unknownz unknowna unknownz\n") + git("add", post.name) + post.write_text("unknownunstaged\n") + before = git("rev-parse", "HEAD").stdout + commit(ok=False) + assert git("rev-parse", "HEAD").stdout == before + assert dictionary.read_text() == "existing\nunknowna\nunknownz\n" + assert git("show", ":.dictionary").stdout == "existing\n" + commit(ok=False) # An immediate retry must not accept unstaged words. + git("add", ".dictionary") + commit() + assert post.read_text() == "unknownunstaged\n" + assert git("show", "HEAD:post with spaces.md").stdout == "unknownz unknowna unknownz\n" + + # Fix a typo instead of accepting it into the dictionary. + post.write_text("unknowntypo\n") + git("add", post.name) + commit(ok=False) + dictionary.write_text(dictionary.read_text().replace("unknowntypo\n", "")) + post.write_text("Correct spelling.\n") + git("add", post.name) + commit() + + # Operational failure must block without modifying the dictionary. + post.write_text("unknownfailure\n") + git("add", post.name) + env['SPELL_FAIL'] = '1' + saved = dictionary.read_bytes() + assert 'Spellcheck failed' in commit(ok=False).stderr + assert dictionary.read_bytes() == saved + del env['SPELL_FAIL'] + # Installation prompts or other stdout diagnostics must never become words. + for status in ('0', '1'): + env['SPELL_NOISE'] = status + assert 'unexpected output' in commit(ok=False).stderr + assert dictionary.read_bytes() == saved + del env['SPELL_NOISE'] + post.write_text("Correct revised spelling.\n") + git("add", post.name) + commit() + + # Accented words follow system sort under the invoking shell's locale. + env['LC_ALL'] = 'en_US.UTF-8' + dictionary.write_text(dictionary.read_text() + "Ørsted\nrésumé\nresume\nzebra\n") + expected = subprocess.check_output(['sort', '-u', str(dictionary)], env=env) + git('add', '.dictionary') + post.write_text('Another spelling check.\n') + git('add', post.name) + commit(ok=False) + assert dictionary.read_bytes() == expected + git('add', '.dictionary') + commit() + + # Renames to .markdown are checked, deletions and non-Markdown are skipped. + git("mv", post.name, "renamed.markdown") + log.write_text("") + commit() + assert log.read_text() == "stdin://renamed.markdown\n" + git("rm", "renamed.markdown") + log.write_text("") + (work / "script.txt").write_text("unknownignored\n") + git("add", "script.txt") + commit() + assert log.read_text() == "" + +print("Spelling hook checks passed: staged content, dictionary review, retries, failures, renames, and deletions.") diff --git a/bin/check-staged-spelling b/bin/check-staged-spelling @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Collect unknown words from staged Markdown and require dictionary review.""" + +from pathlib import Path +import os +import subprocess +import sys +import unicodedata + + +def git(*args): + return subprocess.check_output(["git", *args]) + + +def main(): + os.chdir(os.fsdecode(git("rev-parse", "--show-toplevel")).rstrip("\n")) + paths = git("diff", "--cached", "--name-only", "--diff-filter=ACMR", "-z") + markdown = [os.fsdecode(path) for path in paths.split(b"\0") + if path.lower().endswith((b".md", b".markdown"))] + if not markdown: + return 0 + + dictionary = Path(".dictionary") + original = dictionary.read_bytes() if dictionary.exists() else b"" + words = set(original.decode("utf-8").splitlines()) + for path in markdown: + print(f"Spellchecking staged {path}", file=sys.stderr) + result = subprocess.run( + ["npx", "--yes", "cspell", "--words-only", "--unique", "--no-progress", + "--no-summary", "--config", ".cspell.json", + f"stdin://{path}"], + input=git("show", f":{path}"), stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + # CSpell uses exit 1 for both spelling issues and configuration errors. + # Only accept that status when it produced words and no diagnostics. + if (result.returncode not in (0, 1) or result.stderr + or (result.returncode == 1 and not result.stdout.strip())): + sys.stderr.buffer.write(result.stderr) + print("Spellcheck failed; .dictionary was not updated.", file=sys.stderr) + return 1 + reported = result.stdout.decode("utf-8").splitlines() + if any(not word or not any(char.isalpha() for char in word) + or any(not (char.isalnum() or char in "'’-_" + or unicodedata.category(char).startswith("M")) + for char in word) + for word in reported): + print("Spellcheck returned unexpected output; .dictionary was not updated:\n" + + result.stdout.decode("utf-8"), file=sys.stderr) + return 1 + words.update(reported) + + # Match the user's shell sort, including its locale-specific collation. + updated = subprocess.check_output( + ["sort", "-u"], + input="".join(f"{word}\n" for word in words if word).encode("utf-8"), + ) + if updated != original: + dictionary.write_bytes(updated) + + staged = subprocess.run(["git", "show", ":.dictionary"], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + if updated != original or staged.returncode or staged.stdout != updated: + print("Commit blocked: review .dictionary (git diff -- .dictionary).\n" + "Remove actual typos from it and fix them in your Markdown, then\n" + "stage the corrected files and accepted dictionary words and retry.", + file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except (OSError, UnicodeError, subprocess.CalledProcessError) as error: + sys.exit(f"Spellcheck failed: {error}")