#!/usr/bin/env python3 """Denylist guard for a public repo: refuse private words in commits (git hooks in .githooks/). Usage: denylist_check.py staged | msg FILE | tree [REF] staged added lines + paths of the index (pre-commit hook) msg FILE commit message, '#' lines skipped (commit-msg hook) tree every tracked file of REF (default HEAD): manual audit Lists (merged): publish-denylist.local in the worktree top and in the main tree (git-ignored), plus every `git config denylist.file ` (e.g. a global list). Lines: `word` = case-insensitive substring; `!token` = exemption (a hit inside an occurrence of token does not count); `#` comments. Output names the place and `word #` only, never the word. Exit 0 clean, 1 hits. No list at all → warning, exit 0. Bypass once: `git commit --no-verify`. Enable in a clone: `git config core.hooksPath .githooks`. """ import re import subprocess import sys from pathlib import Path NAME = "publish-denylist.local" def git(*args): r = subprocess.run(["git", "-c", "core.quotePath=false", *args], capture_output=True) if r.returncode: raise SystemExit(f"denylist-check: git {args[0]} failed: {r.stderr.decode(errors='replace').strip()}") return r.stdout.decode("utf-8", errors="replace") def parse(text): """([(lineno, word lowercased)], [exempt token lowercased]).""" words, exempt = [], [] for n, line in enumerate(text.splitlines(), 1): w = line.strip() if not w or w.startswith("#"): continue if w.startswith("!"): if w[1:]: exempt.append(w[1:].lower()) else: words.append((n, w.lower())) return words, exempt def _exempt_spans(low, exempt): spans = [] for t in exempt: spans += [(m.start(), m.end()) for m in re.finditer(re.escape(t), low)] return spans def hits(text, words, exempt): """Line numbers (in the list) of words found in text outside exempted tokens.""" low = text.lower() spans = None out = [] for n, w in words: for m in re.finditer(re.escape(w), low): if spans is None: spans = _exempt_spans(low, exempt) if not any(a <= m.start() and m.end() <= b for a, b in spans): out.append(n) break return out def load_lists(): """[(label, words, exempt)] of every existing list; label '' for the repo list.""" top = Path(git("rev-parse", "--show-toplevel").strip()) common = Path(git("rev-parse", "--path-format=absolute", "--git-common-dir").strip()) cands = [("", top / NAME), ("", common.parent / NAME)] r = subprocess.run(["git", "config", "--get-all", "denylist.file"], capture_output=True, text=True) cands += [(Path(p).name + " ", Path(p).expanduser()) for p in r.stdout.splitlines() if p.strip()] seen, lists = set(), [] for label, p in cands: if not p.is_file() or p.resolve() in seen: continue seen.add(p.resolve()) lists.append((label, *parse(p.read_text(errors="replace")))) return lists def scan(items, lists): """items = [(place, text)] → ['place: