rlsbl 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/rlsbl/__init__.py +2 -4
- package/rlsbl/commands/init_cmd.py +212 -123
- package/templates/shared/claude-settings.json.tpl +1 -13
- package/rlsbl/commands/check_prs.py +0 -26
package/README.md
CHANGED
|
@@ -145,7 +145,7 @@ When you run `release`, the following happens in order:
|
|
|
145
145
|
| `.rlsbl/hooks/post-release.sh` | Shared | User-customizable post-release actions |
|
|
146
146
|
| `.git/hooks/pre-push` | Shared | One-liner that calls `rlsbl pre-push-check` |
|
|
147
147
|
|
|
148
|
-
Hook files are made executable automatically. The `
|
|
148
|
+
Hook files are made executable automatically. The `record-gif` and `pre-push-check` functionality is provided as built-in subcommands (`rlsbl record-gif`, `rlsbl pre-push-check`) rather than scaffolded scripts.
|
|
149
149
|
|
|
150
150
|
The scaffolded `.gitignore` includes a `*.local-only` pattern. Create a `.local-only/` directory or rename files with a `.local-only` suffix to keep them out of version control -- useful for local-only assets, experiments, and keeping the working tree clean for tools that check `git status`.
|
|
151
151
|
|
package/package.json
CHANGED
package/rlsbl/__init__.py
CHANGED
|
@@ -40,7 +40,7 @@ __version__ = _detect_version()
|
|
|
40
40
|
|
|
41
41
|
REGISTRIES = ("npm", "pypi", "go")
|
|
42
42
|
COMMANDS = ("release", "status", "scaffold", "check", "config", "undo", "discover", "watch",
|
|
43
|
-
"
|
|
43
|
+
"pre-push-check", "record-gif")
|
|
44
44
|
COMMAND_ALIASES = {"init": "scaffold"}
|
|
45
45
|
|
|
46
46
|
HELP = f"""\
|
|
@@ -55,7 +55,6 @@ Usage:
|
|
|
55
55
|
rlsbl undo [--yes] Revert the last release
|
|
56
56
|
rlsbl discover [--mine] List rlsbl ecosystem projects
|
|
57
57
|
rlsbl watch [<commit-sha>] Watch CI runs for a commit
|
|
58
|
-
rlsbl check-prs List open PRs (informational)
|
|
59
58
|
rlsbl pre-push-check Verify CHANGELOG entry for current version
|
|
60
59
|
rlsbl record-gif Record a demo GIF with vhs
|
|
61
60
|
|
|
@@ -124,7 +123,6 @@ def _get_command_module(command):
|
|
|
124
123
|
"undo": "undo",
|
|
125
124
|
"discover": "discover",
|
|
126
125
|
"watch": "watch",
|
|
127
|
-
"check-prs": "check_prs",
|
|
128
126
|
"pre-push-check": "pre_push_check",
|
|
129
127
|
"record-gif": "record_gif",
|
|
130
128
|
}
|
|
@@ -226,7 +224,7 @@ def main():
|
|
|
226
224
|
elif command == "watch":
|
|
227
225
|
# watch: monitors CI runs, no registry needed
|
|
228
226
|
handler.run_cmd(registry, args, flags)
|
|
229
|
-
elif command in ("
|
|
227
|
+
elif command in ("pre-push-check", "record-gif"):
|
|
230
228
|
# Standalone commands, no registry needed
|
|
231
229
|
handler.run_cmd(registry, args, flags)
|
|
232
230
|
else:
|
|
@@ -4,25 +4,23 @@ import hashlib
|
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
6
|
import re
|
|
7
|
+
import subprocess
|
|
7
8
|
import sys
|
|
9
|
+
import tempfile
|
|
8
10
|
|
|
9
11
|
from ..config import should_tag
|
|
10
12
|
from ..registries import REGISTRIES
|
|
11
13
|
from ..tagging import ensure_tags
|
|
12
14
|
|
|
13
15
|
HASHES_FILE = os.path.join(".rlsbl", "hashes.json")
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
# Files that are safe to overwrite during --update (managed files users typically don't customize)
|
|
23
|
-
UPDATABLE = {
|
|
24
|
-
".github/workflows/ci.yml",
|
|
25
|
-
".github/workflows/publish.yml",
|
|
16
|
+
BASES_DIR = os.path.join(".rlsbl", "bases")
|
|
17
|
+
|
|
18
|
+
# Files owned by the user after initial scaffold -- never overwrite or merge
|
|
19
|
+
USER_OWNED = {
|
|
20
|
+
"CHANGELOG.md",
|
|
21
|
+
"LICENSE",
|
|
22
|
+
".rlsbl/hooks/pre-release.sh",
|
|
23
|
+
".rlsbl/hooks/post-release.sh",
|
|
26
24
|
}
|
|
27
25
|
|
|
28
26
|
def file_hash(path):
|
|
@@ -84,17 +82,80 @@ def process_template(template_content, vars_dict):
|
|
|
84
82
|
return content, unreplaced
|
|
85
83
|
|
|
86
84
|
|
|
85
|
+
def _save_base(target, content):
|
|
86
|
+
"""Save rendered template content as the merge base for future three-way merges."""
|
|
87
|
+
base_path = os.path.join(BASES_DIR, target)
|
|
88
|
+
os.makedirs(os.path.dirname(base_path), exist_ok=True)
|
|
89
|
+
with open(base_path, "w", encoding="utf-8") as f:
|
|
90
|
+
f.write(content)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _load_base(target):
|
|
94
|
+
"""Load the stored merge base for a target file. Returns None if not stored."""
|
|
95
|
+
base_path = os.path.join(BASES_DIR, target)
|
|
96
|
+
if not os.path.exists(base_path):
|
|
97
|
+
return None
|
|
98
|
+
with open(base_path, "r", encoding="utf-8") as f:
|
|
99
|
+
return f.read()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _three_way_merge(ours_text, base_text, theirs_text):
|
|
103
|
+
"""Three-way merge using git merge-file.
|
|
104
|
+
|
|
105
|
+
Writes three temp files in the project dir (not /tmp), runs
|
|
106
|
+
`git merge-file -p ours base theirs`, and returns (merged_text, has_conflicts).
|
|
107
|
+
Exit code: 0 = clean merge, positive = number of conflicts, negative = error.
|
|
108
|
+
"""
|
|
109
|
+
ours_tmp = theirs_tmp = base_tmp = None
|
|
110
|
+
try:
|
|
111
|
+
ours_tmp = tempfile.NamedTemporaryFile(
|
|
112
|
+
mode="w", suffix=".ours", dir=".", delete=False, encoding="utf-8",
|
|
113
|
+
)
|
|
114
|
+
ours_tmp.write(ours_text)
|
|
115
|
+
ours_tmp.close()
|
|
116
|
+
|
|
117
|
+
base_tmp = tempfile.NamedTemporaryFile(
|
|
118
|
+
mode="w", suffix=".base", dir=".", delete=False, encoding="utf-8",
|
|
119
|
+
)
|
|
120
|
+
base_tmp.write(base_text)
|
|
121
|
+
base_tmp.close()
|
|
122
|
+
|
|
123
|
+
theirs_tmp = tempfile.NamedTemporaryFile(
|
|
124
|
+
mode="w", suffix=".theirs", dir=".", delete=False, encoding="utf-8",
|
|
125
|
+
)
|
|
126
|
+
theirs_tmp.write(theirs_text)
|
|
127
|
+
theirs_tmp.close()
|
|
128
|
+
|
|
129
|
+
result = subprocess.run(
|
|
130
|
+
["git", "merge-file", "-p", ours_tmp.name, base_tmp.name, theirs_tmp.name],
|
|
131
|
+
capture_output=True, text=True,
|
|
132
|
+
)
|
|
133
|
+
merged_text = result.stdout
|
|
134
|
+
# Exit code 0 = clean, positive = number of conflicts, negative = error
|
|
135
|
+
has_conflicts = result.returncode > 0
|
|
136
|
+
if result.returncode < 0:
|
|
137
|
+
# Treat errors as conflicts so the caller knows something went wrong
|
|
138
|
+
has_conflicts = True
|
|
139
|
+
return merged_text, has_conflicts
|
|
140
|
+
finally:
|
|
141
|
+
for tmp in (ours_tmp, base_tmp, theirs_tmp):
|
|
142
|
+
if tmp is not None:
|
|
143
|
+
try:
|
|
144
|
+
os.unlink(tmp.name)
|
|
145
|
+
except OSError:
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
|
|
87
149
|
def process_mappings(template_dir, mappings, vars_dict, force, update=False,
|
|
88
150
|
existing_hashes=None):
|
|
89
151
|
"""Process a list of template mappings: read each template, apply vars, write target files.
|
|
90
152
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
- UPDATABLE files (with --update): overwrite only if the file hasn't been customized
|
|
95
|
-
(detected via SHA-256 hash comparison against stored hashes)
|
|
153
|
+
Uses a universal three-way merge (via git merge-file) for existing files:
|
|
154
|
+
base (last scaffolded version) + ours (user's current file) + theirs (new template).
|
|
155
|
+
USER_OWNED files are never overwritten or merged (except LICENSE year update).
|
|
96
156
|
|
|
97
157
|
Returns (created, skipped, warnings, new_hashes).
|
|
158
|
+
created/skipped are lists of (target, status) tuples for unified display.
|
|
98
159
|
"""
|
|
99
160
|
if existing_hashes is None:
|
|
100
161
|
existing_hashes = {}
|
|
@@ -112,107 +173,132 @@ def process_mappings(template_dir, mappings, vars_dict, force, update=False,
|
|
|
112
173
|
warnings.append(f"Template not found: {template_path}")
|
|
113
174
|
continue
|
|
114
175
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
created.append(target + " (updated)")
|
|
135
|
-
if unreplaced:
|
|
136
|
-
warnings.append(f"{target}: unreplaced vars: {', '.join(unreplaced)}")
|
|
137
|
-
else:
|
|
138
|
-
# File was customized or no stored hash -- skip conservatively
|
|
139
|
-
# Seed the hash so future --update can detect changes
|
|
140
|
-
new_hashes[target] = current_hash
|
|
141
|
-
skipped.append(f"{target} (customized, use --force to overwrite)")
|
|
142
|
-
continue
|
|
176
|
+
with open(template_path, "r", encoding="utf-8") as f:
|
|
177
|
+
raw = f.read()
|
|
178
|
+
theirs, unreplaced = process_template(raw, vars_dict)
|
|
179
|
+
|
|
180
|
+
# --- New file or force overwrite: write and save base ---
|
|
181
|
+
if not os.path.exists(target) or force:
|
|
182
|
+
is_overwrite = os.path.exists(target) and force
|
|
183
|
+
target_dir = os.path.dirname(target)
|
|
184
|
+
if target_dir and target_dir != ".":
|
|
185
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
186
|
+
with open(target, "w", encoding="utf-8") as f:
|
|
187
|
+
f.write(theirs)
|
|
188
|
+
_save_base(target, theirs)
|
|
189
|
+
new_hashes[target] = file_hash(target)
|
|
190
|
+
status = "overwritten" if is_overwrite else "created"
|
|
191
|
+
created.append((target, status))
|
|
192
|
+
if unreplaced:
|
|
193
|
+
warnings.append(f"{target}: unreplaced vars: {', '.join(unreplaced)}")
|
|
194
|
+
continue
|
|
143
195
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
raw = f.read()
|
|
153
|
-
content, _ = process_template(raw, vars_dict)
|
|
154
|
-
lines = content.split("\n")
|
|
155
|
-
first_section_idx = None
|
|
156
|
-
for i, line in enumerate(lines):
|
|
157
|
-
if i > 0 and line.startswith("## "):
|
|
158
|
-
first_section_idx = i
|
|
159
|
-
break
|
|
160
|
-
section = "\n".join(lines[first_section_idx:]) if first_section_idx is not None else content
|
|
161
|
-
with open(target, "a", encoding="utf-8") as f:
|
|
162
|
-
f.write("\n\n" + section.strip() + "\n")
|
|
163
|
-
created.append(target + " (appended)")
|
|
164
|
-
continue
|
|
165
|
-
|
|
166
|
-
if basename in MERGEABLE:
|
|
196
|
+
# --- Existing file, not forced ---
|
|
197
|
+
|
|
198
|
+
# User-owned files: never touch after initial scaffold,
|
|
199
|
+
# except LICENSE gets its copyright year updated on --update.
|
|
200
|
+
if target in USER_OWNED:
|
|
201
|
+
if update and target == "LICENSE":
|
|
202
|
+
from datetime import datetime
|
|
203
|
+
current_year = str(datetime.now().year)
|
|
167
204
|
with open(target, "r", encoding="utf-8") as f:
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
205
|
+
content = f.read()
|
|
206
|
+
# Match "Copyright (c) YYYY" or "Copyright (c) YYYY-YYYY"
|
|
207
|
+
# Capture the original end-year to report the range in the status
|
|
208
|
+
old_year = None
|
|
209
|
+
def _capture_range(m):
|
|
210
|
+
nonlocal old_year
|
|
211
|
+
if m.group(2) == current_year:
|
|
212
|
+
return m.group(0)
|
|
213
|
+
old_year = f"{m.group(1).split()[-1]}-{m.group(2)}"
|
|
214
|
+
return f"{m.group(1)}-{current_year}"
|
|
215
|
+
updated = re.sub(
|
|
216
|
+
r"(Copyright\s+\(c\)\s+\d{4})-(\d{4})",
|
|
217
|
+
_capture_range,
|
|
218
|
+
content,
|
|
219
|
+
)
|
|
220
|
+
if updated == content:
|
|
221
|
+
# No range found or range already current -- try single year
|
|
222
|
+
def _capture_single(m):
|
|
223
|
+
nonlocal old_year
|
|
224
|
+
if m.group(2) == current_year:
|
|
225
|
+
return m.group(0)
|
|
226
|
+
old_year = m.group(2)
|
|
227
|
+
return f"{m.group(1)}{m.group(2)}-{current_year}"
|
|
228
|
+
updated = re.sub(
|
|
229
|
+
r"(Copyright\s+\(c\)\s+)(\d{4})(?![-\d])",
|
|
230
|
+
_capture_single,
|
|
231
|
+
content,
|
|
232
|
+
)
|
|
233
|
+
if updated != content:
|
|
234
|
+
with open(target, "w", encoding="utf-8") as f:
|
|
235
|
+
f.write(updated)
|
|
236
|
+
year_detail = (
|
|
237
|
+
f"year updated ({old_year} -> {old_year.split('-')[0]}-{current_year})"
|
|
238
|
+
if old_year and "-" in old_year
|
|
239
|
+
else f"year updated ({old_year} -> {old_year}-{current_year})"
|
|
240
|
+
) if old_year else "year updated"
|
|
241
|
+
created.append(("LICENSE", year_detail))
|
|
193
242
|
else:
|
|
194
|
-
skipped.append(target
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
skipped.append(target)
|
|
243
|
+
skipped.append((target, "user-owned"))
|
|
244
|
+
else:
|
|
245
|
+
skipped.append((target, "user-owned"))
|
|
198
246
|
continue
|
|
199
247
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
248
|
+
# --- Three-way merge for all other existing files ---
|
|
249
|
+
with open(target, "r", encoding="utf-8") as f:
|
|
250
|
+
ours = f.read()
|
|
251
|
+
base = _load_base(target)
|
|
252
|
+
|
|
253
|
+
if base is None:
|
|
254
|
+
# No base stored (legacy project or first update after migration).
|
|
255
|
+
# Cannot do a three-way merge. Seed the base for next time.
|
|
256
|
+
_save_base(target, theirs)
|
|
257
|
+
if ours == theirs:
|
|
258
|
+
skipped.append((target, "unchanged, base seeded"))
|
|
259
|
+
else:
|
|
260
|
+
warnings.append(
|
|
261
|
+
f"{target}: no base stored, cannot merge; "
|
|
262
|
+
"run scaffold --force to reset"
|
|
263
|
+
)
|
|
264
|
+
skipped.append((target, "no base -- run scaffold --force to enable merging"))
|
|
265
|
+
continue
|
|
213
266
|
|
|
214
|
-
if
|
|
215
|
-
|
|
267
|
+
if ours == base:
|
|
268
|
+
# User did not customize -- clean update: write theirs.
|
|
269
|
+
target_dir = os.path.dirname(target)
|
|
270
|
+
if target_dir and target_dir != ".":
|
|
271
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
272
|
+
with open(target, "w", encoding="utf-8") as f:
|
|
273
|
+
f.write(theirs)
|
|
274
|
+
_save_base(target, theirs)
|
|
275
|
+
new_hashes[target] = file_hash(target)
|
|
276
|
+
created.append((target, "updated"))
|
|
277
|
+
if unreplaced:
|
|
278
|
+
warnings.append(f"{target}: unreplaced vars: {', '.join(unreplaced)}")
|
|
279
|
+
elif base == theirs:
|
|
280
|
+
# Template did not change -- nothing to do.
|
|
281
|
+
skipped.append((target, "unchanged"))
|
|
282
|
+
elif ours == theirs:
|
|
283
|
+
# User and template converged to same content -- nothing to do.
|
|
284
|
+
skipped.append((target, "unchanged"))
|
|
285
|
+
else:
|
|
286
|
+
# Both user and template changed -- three-way merge.
|
|
287
|
+
merged, has_conflicts = _three_way_merge(ours, base, theirs)
|
|
288
|
+
target_dir = os.path.dirname(target)
|
|
289
|
+
if target_dir and target_dir != ".":
|
|
290
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
291
|
+
with open(target, "w", encoding="utf-8") as f:
|
|
292
|
+
f.write(merged)
|
|
293
|
+
_save_base(target, theirs)
|
|
294
|
+
new_hashes[target] = file_hash(target)
|
|
295
|
+
if has_conflicts:
|
|
296
|
+
created.append((target, "CONFLICTS -- resolve manually"))
|
|
297
|
+
warnings.append(f"{target}: merge conflicts detected, resolve manually")
|
|
298
|
+
else:
|
|
299
|
+
created.append((target, "merged"))
|
|
300
|
+
if unreplaced:
|
|
301
|
+
warnings.append(f"{target}: unreplaced vars: {', '.join(unreplaced)}")
|
|
216
302
|
|
|
217
303
|
return created, skipped, warnings, new_hashes
|
|
218
304
|
|
|
@@ -267,16 +353,19 @@ def _finalize_scaffold(existing_hashes, all_hash_dicts, created, skipped, warnin
|
|
|
267
353
|
if should_tag(flags):
|
|
268
354
|
ensure_tags(registries)
|
|
269
355
|
|
|
270
|
-
# Print
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
for
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
356
|
+
# Print unified file list with dot-padded status column
|
|
357
|
+
all_files = [(t, s) for t, s in created] + [(t, s) for t, s in skipped]
|
|
358
|
+
if all_files:
|
|
359
|
+
# Sort by target path for stable output
|
|
360
|
+
all_files.sort(key=lambda item: item[0])
|
|
361
|
+
# Compute padding width: longest target path + minimum 4 dots
|
|
362
|
+
max_target_len = max(len(t) for t, _ in all_files)
|
|
363
|
+
pad_width = max_target_len + 4
|
|
364
|
+
print("Files:")
|
|
365
|
+
for target, status in all_files:
|
|
366
|
+
# Fill gap between target and status with dots
|
|
367
|
+
dots = " " + "." * (pad_width - len(target)) + " "
|
|
368
|
+
print(f" {target}{dots}{status}")
|
|
280
369
|
|
|
281
370
|
if warnings:
|
|
282
371
|
print("Warnings:")
|
|
@@ -285,7 +374,7 @@ def _finalize_scaffold(existing_hashes, all_hash_dicts, created, skipped, warnin
|
|
|
285
374
|
|
|
286
375
|
# Helpful note when existing CI workflow is preserved
|
|
287
376
|
ci_path = ".github/workflows/ci.yml"
|
|
288
|
-
if any(
|
|
377
|
+
if any(t == ci_path for t, _ in skipped):
|
|
289
378
|
print("\nNote: Existing CI workflow preserved. Review and merge manually if needed.")
|
|
290
379
|
|
|
291
380
|
# Next steps
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
"""Check-prs command: list open PRs for awareness."""
|
|
2
|
-
|
|
3
|
-
import shutil
|
|
4
|
-
import subprocess
|
|
5
|
-
import sys
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def run_cmd(registry, args, flags):
|
|
9
|
-
"""List open pull requests in the current repository.
|
|
10
|
-
|
|
11
|
-
Exits silently if gh CLI is not available. Always exits 0 (informational only).
|
|
12
|
-
"""
|
|
13
|
-
if not shutil.which("gh"):
|
|
14
|
-
sys.exit(0)
|
|
15
|
-
|
|
16
|
-
try:
|
|
17
|
-
result = subprocess.run(
|
|
18
|
-
["gh", "pr", "list", "--state", "open", "--limit", "20"],
|
|
19
|
-
capture_output=True, text=True, timeout=15,
|
|
20
|
-
)
|
|
21
|
-
if result.returncode == 0 and result.stdout.strip():
|
|
22
|
-
print(result.stdout.strip())
|
|
23
|
-
except Exception:
|
|
24
|
-
pass
|
|
25
|
-
|
|
26
|
-
sys.exit(0)
|