commit 988a5c21767c58880e6cbdb7d5b6e1e3c657e6a6
parent ecc839244e71e9cdca0ef567073642552572a30e
Author: David Eisinger <[email protected]>
Date: Sat, 10 Oct 2026 15:32:44 -0400
Spellcheck pre-commit hook
Diffstat:
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}")