aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rwxr-xr-xscripts/publish_snapshot.py159
1 files changed, 159 insertions, 0 deletions
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 <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())