From 81d4e80fd5aabe4e80f58e960affa795cf7d34ec Mon Sep 17 00:00:00 2001 From: godosa Date: Wed, 7 Oct 2026 07:27:17 +0200 Subject: workflow: initial public history --- scripts/publish_snapshot.py | 159 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100755 scripts/publish_snapshot.py (limited to 'scripts') diff --git a/scripts/publish_snapshot.py b/scripts/publish_snapshot.py new file mode 100755 index 0000000..bcbb002 --- /dev/null +++ b/scripts/publish_snapshot.py @@ -0,0 +1,159 @@ +#!/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 /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 /{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()) -- cgit