aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/scripts/publish_snapshot.py
blob: bcbb0027b234b8f44489c40dfb370abc178d2e93 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
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())