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())
|