aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/scripts
diff options
context:
space:
mode:
authorgodosa <godosa@godosa.eu>2026-10-07 07:44:16 +0200
committergodosa <godosa@godosa.eu>2026-10-07 07:44:16 +0200
commit9d5641fe6585a61894f7b7427a68c794e5efe76d (patch)
tree79ebae3131df1c50542420db9e5ecb44e3d22076 /scripts
parent81d4e80fd5aabe4e80f58e960affa795cf7d34ec (diff)
downloadworkflow-9d5641fe6585a61894f7b7427a68c794e5efe76d.tar.gz
workflow-9d5641fe6585a61894f7b7427a68c794e5efe76d.zip
Denylist guard at commit time (pre-commit + commit-msg hooks); drop publish_snapshot
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JEAjUkQRrCYX5MZhWxdtj2
Diffstat (limited to 'scripts')
-rwxr-xr-xscripts/denylist_check.py158
-rwxr-xr-xscripts/publish_snapshot.py159
2 files changed, 158 insertions, 159 deletions
diff --git a/scripts/denylist_check.py b/scripts/denylist_check.py
new file mode 100755
index 0000000..08cf985
--- /dev/null
+++ b/scripts/denylist_check.py
@@ -0,0 +1,158 @@
+#!/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:]))
diff --git a/scripts/publish_snapshot.py b/scripts/publish_snapshot.py
deleted file mode 100755
index bcbb002..0000000
--- a/scripts/publish_snapshot.py
+++ /dev/null
@@ -1,159 +0,0 @@
-#!/usr/bin/env python3
-"""Export a scrubbed snapshot of the tool repo into a separate local git repo (fresh history).
-
-Usage: publish_snapshot.py [DEST] [--src REPO] [--ref REF] [--denylist FILE]
-
-- DEST (default ~/src/wf-public): created and `git init`ed on the first run.
-- The tree of REF (default master) is copied; inbox.md, .worktrees/, __pycache__/, out/ are dropped.
-- Denylist (default <src>/publish-denylist.local, git-ignored): one private word per line, `#` comments;
- matched case-insensitively against every path and file content. Any match → nothing is written, exit 1.
-- First run → one commit 'initial public snapshot'; later runs → one commit whose message is the
- CHANGES.md lines new since the last snapshot; no change → no commit.
-- Never pushes, never adds a remote: pushing is a manual step (docs/manual.md).
-"""
-import argparse
-import io
-import os
-import shutil
-import subprocess
-import sys
-import tarfile
-from pathlib import Path
-
-EXCLUDE_NAMES = {"inbox.md", ".worktrees", "__pycache__", "out"}
-DENYLIST_NAME = "publish-denylist.local"
-
-
-class Fail(Exception):
- pass
-
-
-def git(cwd, *args, data=None):
- r = subprocess.run(["git", *args], cwd=cwd, input=data, capture_output=True)
- if r.returncode:
- raise Fail(f"git {' '.join(args)}: {r.stderr.decode(errors='replace').strip()}")
- return r.stdout
-
-
-def excluded(path):
- parts = path.split("/")
- return any(p in EXCLUDE_NAMES for p in parts)
-
-
-def read_tree(src, ref):
- """{relative path: (bytes, mode)} of REF's tree, excluded paths dropped."""
- tar = tarfile.open(fileobj=io.BytesIO(git(src, "archive", "--format=tar", ref)))
- files = {}
- for m in tar.getmembers():
- if not (m.isfile() or m.issym()) or excluded(m.name):
- continue
- if m.issym():
- files[m.name] = (m.linkname.encode(), "link")
- else:
- files[m.name] = (tar.extractfile(m).read(), m.mode)
- return files
-
-
-def load_denylist(path):
- if not path.is_file():
- raise Fail(f"no denylist at {path} (one private word per line; git-ignored) — refusing to publish")
- words = [w.strip() for w in path.read_text().splitlines()]
- words = [w for w in words if w and not w.startswith("#")]
- if not words:
- raise Fail(f"denylist {path} is empty — refusing to publish")
- return words
-
-
-def scan(files, words):
- """Lines 'path[:line]: word' for every denylist hit."""
- low = [w.lower() for w in words]
- hits = []
- for path in sorted(files):
- for w, wl in zip(words, low):
- if wl in path.lower():
- hits.append(f"{path}: {w} (path)")
- text = files[path][0].decode("utf-8", errors="ignore").lower()
- if not any(wl in text for wl in low):
- continue
- for n, line in enumerate(text.splitlines(), 1):
- for w, wl in zip(words, low):
- if wl in line:
- hits.append(f"{path}:{n}: {w}")
- return hits
-
-
-def changes_lines(data):
- return [l for l in data.decode("utf-8", errors="replace").splitlines() if l.startswith("- ")]
-
-
-def write_tree(dest, files):
- for p in dest.iterdir():
- if p.name == ".git":
- continue
- shutil.rmtree(p) if p.is_dir() and not p.is_symlink() else p.unlink()
- for path, (data, mode) in files.items():
- f = dest / path
- f.parent.mkdir(parents=True, exist_ok=True)
- if mode == "link":
- os.symlink(data.decode(), f)
- else:
- f.write_bytes(data)
- os.chmod(f, 0o755 if mode & 0o111 else 0o644)
-
-
-def publish(src, dest, ref, denylist):
- words = load_denylist(denylist)
- files = read_tree(src, ref)
- hits = scan(files, words)
- if hits:
- raise Fail("denylist matches, nothing published:\n" + "\n".join(hits))
- first = not (dest / ".git").exists()
- if first:
- if dest.exists() and any(dest.iterdir()):
- raise Fail(f"{dest} exists, is not empty and not a git repo")
- dest.mkdir(parents=True, exist_ok=True)
- git(dest, "init", "-q", "-b", "master")
- old_changes = []
- else:
- old = dest / "CHANGES.md"
- old_changes = changes_lines(old.read_bytes()) if old.is_file() else []
- write_tree(dest, files)
- git(dest, "add", "-A")
- if not first and not git(dest, "status", "--porcelain").strip():
- return "nothing new: no commit"
- if first:
- msg = "initial public snapshot"
- else:
- new = [l for l in changes_lines(files.get("CHANGES.md", (b"", 0))[0]) if l not in set(old_changes)]
- msg = "public snapshot\n\n" + ("\n".join(new) if new else "- (no new CHANGES.md lines)")
- ident = [] # a fresh dest has no identity of its own: commit as the source repo's user
- for key in ("user.name", "user.email"):
- r = subprocess.run(["git", "config", key], cwd=src, capture_output=True, text=True)
- if r.stdout.strip():
- ident += ["-c", f"{key}={r.stdout.strip()}"]
- git(dest, *ident, "commit", "-q", "-F", "-", data=msg.encode())
- sha = git(dest, "rev-parse", "--short", "HEAD").decode().strip()
- return f"committed {sha} in {dest} ({'first' if first else 'update'}; not pushed — see docs/manual.md)"
-
-
-def main(argv=None):
- here = Path(__file__).resolve().parent.parent
- ap = argparse.ArgumentParser(prog="publish_snapshot.py", description=__doc__.splitlines()[0])
- ap.add_argument("dest", nargs="?", default=str(Path.home() / "src" / "wf-public"),
- help="target repo (default ~/src/wf-public)")
- ap.add_argument("--src", default=str(here), help="tool repo (default: this script's repo)")
- ap.add_argument("--ref", default="master", help="source ref (default master)")
- ap.add_argument("--denylist", help=f"private-word file (default <src>/{DENYLIST_NAME})")
- a = ap.parse_args(argv)
- src = Path(a.src).expanduser().resolve()
- deny = Path(a.denylist).expanduser() if a.denylist else src / DENYLIST_NAME
- try:
- print(publish(src, Path(a.dest).expanduser().resolve(), a.ref, deny))
- except Fail as e:
- print(f"wf: {e}", file=sys.stderr)
- return 1
- return 0
-
-
-if __name__ == "__main__":
- sys.exit(main())