workflow

git clone https://git.godosa.eu/workflow

master

raw · 5998 bytes

#!/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 <path>` (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 #<line>` 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: <label>word #n']; words in place (a path) masked as ***."""
    allw = sorted({w for _, words, _ in lists for _, w in words}, key=len, reverse=True)
    mask = re.compile("|".join(map(re.escape, allw)), re.I) if allw else None
    out = []
    for place, text in items:
        for label, words, exempt in lists:
            out += [f"{mask.sub('***', place)}: {label}word #{n}" for n in hits(text, words, exempt)]
    return out


def staged_items():
    items, path, line = [], None, 0
    for name in git("diff", "--cached", "--name-only", "-z", "--diff-filter=ACMR").split("\0"):
        if name:
            items.append((f"path {name}", name))
    for raw in git("diff", "--cached", "-U0", "--no-color", "--no-ext-diff", "--diff-filter=ACMR").splitlines():
        if raw.startswith("+++ "):
            path = raw[6:] if raw.startswith("+++ b/") else raw[4:].strip('"')
        elif raw.startswith("@@"):
            m = re.match(r"@@ -\S+ \+(\d+)", raw)
            line = int(m.group(1)) if m else 0
        elif raw.startswith("+") and path:
            items.append((f"{path}:{line}", raw[1:]))
            line += 1
    return items


def msg_items(file):
    text = Path(file).read_text(errors="replace")
    items = []
    for n, l in enumerate(text.splitlines(), 1):
        if l.startswith("# ------------------------ >8"):
            break
        if not l.startswith("#"):
            items.append((f"commit message:{n}", l))
    return items


def tree_items(ref):
    items = []
    for name in git("ls-tree", "-r", "-z", "--name-only", ref).split("\0"):
        if not name:
            continue
        items.append((f"path {name}", name))
        data = subprocess.run(["git", "cat-file", "blob", f"{ref}:{name}"], capture_output=True).stdout
        if b"\0" in data[:8000]:
            continue
        for n, l in enumerate(data.decode("utf-8", errors="replace").splitlines(), 1):
            items.append((f"{name}:{n}", l))
    return items


def main(argv):
    if not argv or argv[0] not in ("staged", "msg", "tree") or (argv[0] == "msg" and len(argv) != 2):
        print(__doc__.split("\n\n")[1], file=sys.stderr)
        return 2
    lists = load_lists()
    if not lists:
        print(f"denylist-check: warning: no {NAME} (nor denylist.file): nothing checked", file=sys.stderr)
        return 0
    mode = argv[0]
    items = staged_items() if mode == "staged" else msg_items(argv[1]) if mode == "msg" \
        else tree_items(argv[1] if len(argv) > 1 else "HEAD")
    found = scan(items, lists)
    if found:
        print("denylist-check: private word found (fix, or `git commit --no-verify` if a false hit):",
              file=sys.stderr)
        print("\n".join(found), file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))