rlsbl 0.7.0 → 0.8.1
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/package.json +1 -1
- package/rlsbl/commands/discover.py +4 -2
- package/rlsbl/commands/init_cmd.py +197 -272
- package/rlsbl/commands/release.py +12 -0
- package/rlsbl/commands/undo.py +11 -3
- package/rlsbl/registries/go.py +2 -2
- package/rlsbl/registries/npm.py +3 -3
- package/rlsbl/registries/pypi.py +2 -2
- package/rlsbl/tagging.py +8 -3
- package/{templates → rlsbl/templates}/shared/hooks/pre-release.sh.tpl +9 -5
- /package/{templates → rlsbl/templates}/go/VERSION.tpl +0 -0
- /package/{templates → rlsbl/templates}/go/ci.yml.tpl +0 -0
- /package/{templates → rlsbl/templates}/go/goreleaser.yml.tpl +0 -0
- /package/{templates → rlsbl/templates}/go/publish.yml.tpl +0 -0
- /package/{templates → rlsbl/templates}/merged/publish.yml.tpl +0 -0
- /package/{templates → rlsbl/templates}/npm/ci.yml.tpl +0 -0
- /package/{templates → rlsbl/templates}/npm/publish.yml.tpl +0 -0
- /package/{templates → rlsbl/templates}/pypi/ci.yml.tpl +0 -0
- /package/{templates → rlsbl/templates}/pypi/publish.yml.tpl +0 -0
- /package/{templates → rlsbl/templates}/shared/CHANGELOG.md.tpl +0 -0
- /package/{templates → rlsbl/templates}/shared/CLAUDE.md.tpl +0 -0
- /package/{templates → rlsbl/templates}/shared/LICENSE.tpl +0 -0
- /package/{templates → rlsbl/templates}/shared/claude-settings.json.tpl +0 -0
- /package/{templates → rlsbl/templates}/shared/gitignore.tpl +0 -0
- /package/{templates → rlsbl/templates}/shared/hooks/post-release.sh.tpl +0 -0
package/package.json
CHANGED
|
@@ -47,10 +47,12 @@ def _parse_next_link(headers):
|
|
|
47
47
|
return None
|
|
48
48
|
for part in link.split(","):
|
|
49
49
|
if 'rel="next"' in part:
|
|
50
|
-
# Extract URL between < and >
|
|
51
50
|
start = part.index("<") + 1
|
|
52
51
|
end = part.index(">")
|
|
53
|
-
|
|
52
|
+
url = part[start:end]
|
|
53
|
+
if not url.startswith("https://api.github.com/"):
|
|
54
|
+
return None
|
|
55
|
+
return url
|
|
54
56
|
return None
|
|
55
57
|
|
|
56
58
|
|
|
@@ -4,30 +4,16 @@ import hashlib
|
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
6
|
import re
|
|
7
|
+
import subprocess
|
|
7
8
|
import sys
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
from ruamel.yaml import YAML
|
|
9
|
+
import tempfile
|
|
11
10
|
|
|
12
11
|
from ..config import should_tag
|
|
13
12
|
from ..registries import REGISTRIES
|
|
14
13
|
from ..tagging import ensure_tags
|
|
15
14
|
|
|
16
15
|
HASHES_FILE = os.path.join(".rlsbl", "hashes.json")
|
|
17
|
-
|
|
18
|
-
# Files where existing content is preserved and template sections are appended
|
|
19
|
-
APPENDABLE = {"CLAUDE.md"}
|
|
20
|
-
APPEND_MARKER = "rlsbl"
|
|
21
|
-
|
|
22
|
-
# Files where missing entries from the template are merged into the existing file
|
|
23
|
-
MERGEABLE = {".gitignore"}
|
|
24
|
-
|
|
25
|
-
# JSON files where template keys are deep-merged into existing user content
|
|
26
|
-
JSON_MERGEABLE = {".claude/settings.json"}
|
|
27
|
-
|
|
28
|
-
# YAML workflow files with job-level merge: template jobs overwrite same-key user jobs,
|
|
29
|
-
# user-added jobs are preserved. Top-level keys (name, on, permissions) come from template.
|
|
30
|
-
YAML_MERGEABLE = {".github/workflows/ci.yml", ".github/workflows/publish.yml"}
|
|
16
|
+
BASES_DIR = os.path.join(".rlsbl", "bases")
|
|
31
17
|
|
|
32
18
|
# Files owned by the user after initial scaffold -- never overwrite or merge
|
|
33
19
|
USER_OWNED = {
|
|
@@ -96,110 +82,80 @@ def process_template(template_content, vars_dict):
|
|
|
96
82
|
return content, unreplaced
|
|
97
83
|
|
|
98
84
|
|
|
99
|
-
def
|
|
100
|
-
"""
|
|
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)
|
|
101
91
|
|
|
102
|
-
- Dicts: recursively merge; keys only in override are added, base keys kept.
|
|
103
|
-
- Lists: union by value; base items first, then new override items (deduplicated).
|
|
104
|
-
- Scalars: base wins.
|
|
105
|
-
"""
|
|
106
|
-
if isinstance(base, dict) and isinstance(override, dict):
|
|
107
|
-
merged = {}
|
|
108
|
-
for key in base:
|
|
109
|
-
if key in override:
|
|
110
|
-
merged[key] = _deep_merge(base[key], override[key])
|
|
111
|
-
else:
|
|
112
|
-
merged[key] = base[key]
|
|
113
|
-
for key in override:
|
|
114
|
-
if key not in base:
|
|
115
|
-
merged[key] = override[key]
|
|
116
|
-
return merged
|
|
117
|
-
if isinstance(base, list) and isinstance(override, list):
|
|
118
|
-
# Deduplicate by value: keep base order, append new items from override
|
|
119
|
-
seen = set()
|
|
120
|
-
for item in base:
|
|
121
|
-
# Use JSON serialization for unhashable items (dicts, lists)
|
|
122
|
-
try:
|
|
123
|
-
seen.add(item)
|
|
124
|
-
except TypeError:
|
|
125
|
-
seen.add(json.dumps(item, sort_keys=True))
|
|
126
|
-
result = list(base)
|
|
127
|
-
for item in override:
|
|
128
|
-
if isinstance(item, (dict, list)):
|
|
129
|
-
key = json.dumps(item, sort_keys=True)
|
|
130
|
-
else:
|
|
131
|
-
key = item
|
|
132
|
-
if key not in seen:
|
|
133
|
-
seen.add(key)
|
|
134
|
-
result.append(item)
|
|
135
|
-
return result
|
|
136
|
-
# Scalars: base (user) wins
|
|
137
|
-
return base
|
|
138
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()
|
|
139
100
|
|
|
140
|
-
def _yaml_merge_workflow(existing_text, template_text):
|
|
141
|
-
"""Merge a YAML workflow at the job level.
|
|
142
101
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
user-added jobs not in the template are preserved at the end.
|
|
102
|
+
def _three_way_merge(ours_text, base_text, theirs_text):
|
|
103
|
+
"""Three-way merge using git merge-file.
|
|
146
104
|
|
|
147
|
-
|
|
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.
|
|
148
108
|
"""
|
|
149
|
-
|
|
150
|
-
yaml.preserve_quotes = True
|
|
151
|
-
try:
|
|
152
|
-
existing = yaml.load(existing_text)
|
|
153
|
-
except Exception as e:
|
|
154
|
-
return None, f"failed to parse existing YAML: {e}"
|
|
109
|
+
ours_tmp = theirs_tmp = base_tmp = None
|
|
155
110
|
try:
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
191
147
|
|
|
192
148
|
|
|
193
149
|
def process_mappings(template_dir, mappings, vars_dict, force, update=False,
|
|
194
150
|
existing_hashes=None):
|
|
195
151
|
"""Process a list of template mappings: read each template, apply vars, write target files.
|
|
196
152
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
- YAML_MERGEABLE files: job-level merge for CI workflows (template jobs win, user jobs preserved)
|
|
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).
|
|
156
|
+
|
|
202
157
|
Returns (created, skipped, warnings, new_hashes).
|
|
158
|
+
created/skipped are lists of (target, status) tuples for unified display.
|
|
203
159
|
"""
|
|
204
160
|
if existing_hashes is None:
|
|
205
161
|
existing_hashes = {}
|
|
@@ -217,166 +173,132 @@ def process_mappings(template_dir, mappings, vars_dict, force, update=False,
|
|
|
217
173
|
warnings.append(f"Template not found: {template_path}")
|
|
218
174
|
continue
|
|
219
175
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
|
195
|
+
|
|
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)
|
|
204
|
+
with open(target, "r", encoding="utf-8") as f:
|
|
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}"
|
|
233
228
|
updated = re.sub(
|
|
234
|
-
r"(Copyright\s+\(c\)\s
|
|
235
|
-
|
|
236
|
-
m.group(0) if m.group(2) == current_year
|
|
237
|
-
else f"{m.group(1)}-{current_year}"
|
|
238
|
-
),
|
|
229
|
+
r"(Copyright\s+\(c\)\s+)(\d{4})(?![-\d])",
|
|
230
|
+
_capture_single,
|
|
239
231
|
content,
|
|
240
232
|
)
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
)
|
|
251
|
-
if updated != content:
|
|
252
|
-
with open(target, "w", encoding="utf-8") as f:
|
|
253
|
-
f.write(updated)
|
|
254
|
-
created.append("LICENSE (year updated)")
|
|
255
|
-
else:
|
|
256
|
-
skipped.append(target)
|
|
257
|
-
else:
|
|
258
|
-
skipped.append(target)
|
|
259
|
-
continue
|
|
260
|
-
|
|
261
|
-
if target in JSON_MERGEABLE:
|
|
262
|
-
with open(target, "r", encoding="utf-8") as f:
|
|
263
|
-
existing_text = f.read()
|
|
264
|
-
with open(template_path, "r", encoding="utf-8") as f:
|
|
265
|
-
raw = f.read()
|
|
266
|
-
content, unreplaced = process_template(raw, vars_dict)
|
|
267
|
-
try:
|
|
268
|
-
existing_data = json.loads(existing_text)
|
|
269
|
-
template_data = json.loads(content)
|
|
270
|
-
except json.JSONDecodeError as e:
|
|
271
|
-
warnings.append(f"{target}: JSON parse error during merge: {e}")
|
|
272
|
-
skipped.append(target)
|
|
273
|
-
continue
|
|
274
|
-
merged = _deep_merge(existing_data, template_data)
|
|
275
|
-
target_dir = os.path.dirname(target)
|
|
276
|
-
if target_dir and target_dir != ".":
|
|
277
|
-
os.makedirs(target_dir, exist_ok=True)
|
|
278
|
-
with open(target, "w", encoding="utf-8") as f:
|
|
279
|
-
f.write(json.dumps(merged, indent=2))
|
|
280
|
-
f.write("\n")
|
|
281
|
-
created.append(target + " (merged)")
|
|
282
|
-
if unreplaced:
|
|
283
|
-
warnings.append(f"{target}: unreplaced vars: {', '.join(unreplaced)}")
|
|
284
|
-
continue
|
|
285
|
-
|
|
286
|
-
if target in YAML_MERGEABLE:
|
|
287
|
-
with open(target, "r", encoding="utf-8") as f:
|
|
288
|
-
existing_text = f.read()
|
|
289
|
-
with open(template_path, "r", encoding="utf-8") as f:
|
|
290
|
-
raw = f.read()
|
|
291
|
-
content, unreplaced = process_template(raw, vars_dict)
|
|
292
|
-
merged_text, err = _yaml_merge_workflow(existing_text, content)
|
|
293
|
-
if err:
|
|
294
|
-
warnings.append(f"{target}: YAML merge skipped: {err}")
|
|
295
|
-
skipped.append(target)
|
|
296
|
-
continue
|
|
297
|
-
target_dir = os.path.dirname(target)
|
|
298
|
-
if target_dir and target_dir != ".":
|
|
299
|
-
os.makedirs(target_dir, exist_ok=True)
|
|
300
|
-
with open(target, "w", encoding="utf-8") as f:
|
|
301
|
-
f.write(merged_text)
|
|
302
|
-
new_hashes[target] = file_hash(target)
|
|
303
|
-
created.append(target + " (merged)")
|
|
304
|
-
if unreplaced:
|
|
305
|
-
warnings.append(f"{target}: unreplaced vars: {', '.join(unreplaced)}")
|
|
306
|
-
continue
|
|
307
|
-
|
|
308
|
-
if basename in APPENDABLE:
|
|
309
|
-
with open(target, "r", encoding="utf-8") as f:
|
|
310
|
-
existing = f.read()
|
|
311
|
-
if APPEND_MARKER in existing:
|
|
312
|
-
skipped.append(target + " (already has rlsbl section)")
|
|
313
|
-
continue
|
|
314
|
-
# Append only the ## sections, stripping the top-level # heading
|
|
315
|
-
with open(template_path, "r", encoding="utf-8") as f:
|
|
316
|
-
raw = f.read()
|
|
317
|
-
content, _ = process_template(raw, vars_dict)
|
|
318
|
-
lines = content.split("\n")
|
|
319
|
-
first_section_idx = None
|
|
320
|
-
for i, line in enumerate(lines):
|
|
321
|
-
if i > 0 and line.startswith("## "):
|
|
322
|
-
first_section_idx = i
|
|
323
|
-
break
|
|
324
|
-
section = "\n".join(lines[first_section_idx:]) if first_section_idx is not None else content
|
|
325
|
-
with open(target, "a", encoding="utf-8") as f:
|
|
326
|
-
f.write("\n\n" + section.strip() + "\n")
|
|
327
|
-
created.append(target + " (appended)")
|
|
328
|
-
continue
|
|
329
|
-
|
|
330
|
-
if basename in MERGEABLE:
|
|
331
|
-
with open(target, "r", encoding="utf-8") as f:
|
|
332
|
-
existing = f.read()
|
|
333
|
-
with open(template_path, "r", encoding="utf-8") as f:
|
|
334
|
-
raw = f.read()
|
|
335
|
-
content, _ = process_template(raw, vars_dict)
|
|
336
|
-
existing_lines = {
|
|
337
|
-
line.strip() for line in existing.split("\n") if line.strip()
|
|
338
|
-
}
|
|
339
|
-
# Normalize by stripping trailing slashes so e.g.
|
|
340
|
-
# "*.egg-info/" matches "*.egg-info" and vice versa.
|
|
341
|
-
existing_normalized = {
|
|
342
|
-
line.rstrip("/") for line in existing_lines
|
|
343
|
-
}
|
|
344
|
-
new_lines = [
|
|
345
|
-
line.strip() for line in content.split("\n") if line.strip()
|
|
346
|
-
]
|
|
347
|
-
# Only merge non-comment entries that are missing from the existing file
|
|
348
|
-
missing = [
|
|
349
|
-
line for line in new_lines
|
|
350
|
-
if line.rstrip("/") not in existing_normalized
|
|
351
|
-
and not line.startswith("#")
|
|
352
|
-
]
|
|
353
|
-
if missing:
|
|
354
|
-
with open(target, "a", encoding="utf-8") as f:
|
|
355
|
-
f.write("\n# Added by rlsbl\n" + "\n".join(missing) + "\n")
|
|
356
|
-
created.append(f"{target} (merged {len(missing)} entries)")
|
|
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))
|
|
357
242
|
else:
|
|
358
|
-
skipped.append(target
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
skipped.append(target)
|
|
243
|
+
skipped.append((target, "user-owned"))
|
|
244
|
+
else:
|
|
245
|
+
skipped.append((target, "user-owned"))
|
|
362
246
|
continue
|
|
363
247
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
|
377
266
|
|
|
378
|
-
if
|
|
379
|
-
|
|
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)}")
|
|
380
302
|
|
|
381
303
|
return created, skipped, warnings, new_hashes
|
|
382
304
|
|
|
@@ -431,16 +353,19 @@ def _finalize_scaffold(existing_hashes, all_hash_dicts, created, skipped, warnin
|
|
|
431
353
|
if should_tag(flags):
|
|
432
354
|
ensure_tags(registries)
|
|
433
355
|
|
|
434
|
-
# Print
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
for
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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}")
|
|
444
369
|
|
|
445
370
|
if warnings:
|
|
446
371
|
print("Warnings:")
|
|
@@ -449,7 +374,7 @@ def _finalize_scaffold(existing_hashes, all_hash_dicts, created, skipped, warnin
|
|
|
449
374
|
|
|
450
375
|
# Helpful note when existing CI workflow is preserved
|
|
451
376
|
ci_path = ".github/workflows/ci.yml"
|
|
452
|
-
if any(
|
|
377
|
+
if any(t == ci_path for t, _ in skipped):
|
|
453
378
|
print("\nNote: Existing CI workflow preserved. Review and merge manually if needed.")
|
|
454
379
|
|
|
455
380
|
# Next steps
|
|
@@ -555,7 +480,7 @@ def run_cmd_multi(registries_list, args, flags):
|
|
|
555
480
|
|
|
556
481
|
# Process merged publish workflow template
|
|
557
482
|
merged_tpl_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)),
|
|
558
|
-
"
|
|
483
|
+
"templates", "merged")
|
|
559
484
|
merged_created, merged_skipped, merged_warnings, merged_hashes = process_mappings(
|
|
560
485
|
merged_tpl_dir,
|
|
561
486
|
[{"template": "publish.yml.tpl", "target": ".github/workflows/publish.yml"}],
|
|
@@ -210,6 +210,18 @@ def run_cmd(registry, args, flags):
|
|
|
210
210
|
except Exception:
|
|
211
211
|
pass
|
|
212
212
|
|
|
213
|
+
# Update .rlsbl/version marker so it's included in the release commit
|
|
214
|
+
rlsbl_version_marker = os.path.join(".rlsbl", "version")
|
|
215
|
+
if os.path.exists(os.path.dirname(rlsbl_version_marker)):
|
|
216
|
+
try:
|
|
217
|
+
from .. import __version__ as rlsbl_ver
|
|
218
|
+
with open(rlsbl_version_marker, "w") as f:
|
|
219
|
+
f.write(rlsbl_ver + "\n")
|
|
220
|
+
if rlsbl_version_marker not in files_to_commit:
|
|
221
|
+
files_to_commit.append(rlsbl_version_marker)
|
|
222
|
+
except Exception:
|
|
223
|
+
pass
|
|
224
|
+
|
|
213
225
|
# Commit if anything was actually modified (version bump or tagging)
|
|
214
226
|
needs_commit = new_version != current_version or not is_clean_tree()
|
|
215
227
|
if files_to_commit and needs_commit:
|
package/rlsbl/commands/undo.py
CHANGED
|
@@ -2,12 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
import sys
|
|
4
4
|
|
|
5
|
-
from ..utils import run, check_gh_installed, check_gh_auth, get_push_timeout
|
|
5
|
+
from ..utils import run, check_gh_installed, check_gh_auth, get_push_timeout, is_clean_tree
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
def run_cmd(registry, args, flags):
|
|
9
|
-
check_gh_installed()
|
|
10
|
-
|
|
9
|
+
if not check_gh_installed():
|
|
10
|
+
print("Error: gh CLI is not installed.", file=sys.stderr)
|
|
11
|
+
sys.exit(1)
|
|
12
|
+
if not check_gh_auth():
|
|
13
|
+
print("Error: gh CLI is not authenticated.", file=sys.stderr)
|
|
14
|
+
sys.exit(1)
|
|
15
|
+
|
|
16
|
+
if not is_clean_tree():
|
|
17
|
+
print("Error: working tree is not clean. Commit your changes first.", file=sys.stderr)
|
|
18
|
+
sys.exit(1)
|
|
11
19
|
|
|
12
20
|
# Find the latest tag
|
|
13
21
|
try:
|
package/rlsbl/registries/go.py
CHANGED
|
@@ -39,12 +39,12 @@ def get_version_file():
|
|
|
39
39
|
|
|
40
40
|
def get_template_dir():
|
|
41
41
|
"""Returns path to the go-specific template directory."""
|
|
42
|
-
return os.path.join(os.path.dirname(__file__), "..", "
|
|
42
|
+
return os.path.join(os.path.dirname(__file__), "..", "templates", "go")
|
|
43
43
|
|
|
44
44
|
|
|
45
45
|
def get_shared_template_dir():
|
|
46
46
|
"""Returns path to the shared template directory."""
|
|
47
|
-
return os.path.join(os.path.dirname(__file__), "..", "
|
|
47
|
+
return os.path.join(os.path.dirname(__file__), "..", "templates", "shared")
|
|
48
48
|
|
|
49
49
|
|
|
50
50
|
def get_template_vars(dir_path):
|
package/rlsbl/registries/npm.py
CHANGED
|
@@ -32,7 +32,7 @@ def write_version(dir_path, version):
|
|
|
32
32
|
|
|
33
33
|
# Preserve trailing newline if present
|
|
34
34
|
trailing_newline = "\n" if raw.endswith("\n") else ""
|
|
35
|
-
output = json.dumps(pkg, indent=indent) + trailing_newline
|
|
35
|
+
output = json.dumps(pkg, indent=indent, ensure_ascii=False) + trailing_newline
|
|
36
36
|
# Atomic write: write to temp file, then rename
|
|
37
37
|
tmp_path = pkg_path + ".tmp"
|
|
38
38
|
with open(tmp_path, "w", encoding="utf-8") as f:
|
|
@@ -47,12 +47,12 @@ def get_version_file():
|
|
|
47
47
|
|
|
48
48
|
def get_template_dir():
|
|
49
49
|
"""Returns path to the npm-specific template directory."""
|
|
50
|
-
return os.path.join(os.path.dirname(__file__), "..", "
|
|
50
|
+
return os.path.join(os.path.dirname(__file__), "..", "templates", "npm")
|
|
51
51
|
|
|
52
52
|
|
|
53
53
|
def get_shared_template_dir():
|
|
54
54
|
"""Returns path to the shared template directory."""
|
|
55
|
-
return os.path.join(os.path.dirname(__file__), "..", "
|
|
55
|
+
return os.path.join(os.path.dirname(__file__), "..", "templates", "shared")
|
|
56
56
|
|
|
57
57
|
|
|
58
58
|
def get_template_vars(dir_path):
|
package/rlsbl/registries/pypi.py
CHANGED
|
@@ -67,12 +67,12 @@ def get_version_file():
|
|
|
67
67
|
|
|
68
68
|
def get_template_dir():
|
|
69
69
|
"""Returns path to the pypi-specific template directory."""
|
|
70
|
-
return os.path.join(os.path.dirname(__file__), "..", "
|
|
70
|
+
return os.path.join(os.path.dirname(__file__), "..", "templates", "pypi")
|
|
71
71
|
|
|
72
72
|
|
|
73
73
|
def get_shared_template_dir():
|
|
74
74
|
"""Returns path to the shared template directory."""
|
|
75
|
-
return os.path.join(os.path.dirname(__file__), "..", "
|
|
75
|
+
return os.path.join(os.path.dirname(__file__), "..", "templates", "shared")
|
|
76
76
|
|
|
77
77
|
|
|
78
78
|
def get_template_vars(dir_path):
|
package/rlsbl/tagging.py
CHANGED
|
@@ -32,7 +32,7 @@ def ensure_npm_keyword(dir_path=".", quiet=False):
|
|
|
32
32
|
|
|
33
33
|
# Preserve trailing newline if present
|
|
34
34
|
trailing_newline = "\n" if raw.endswith("\n") else ""
|
|
35
|
-
output = json.dumps(pkg, indent=indent) + trailing_newline
|
|
35
|
+
output = json.dumps(pkg, indent=indent, ensure_ascii=False) + trailing_newline
|
|
36
36
|
|
|
37
37
|
# Atomic write: write to temp file, then rename
|
|
38
38
|
tmp_path = pkg_path + ".tmp"
|
|
@@ -82,11 +82,16 @@ def ensure_pypi_keyword(dir_path=".", quiet=False):
|
|
|
82
82
|
# Find the indent used for existing items
|
|
83
83
|
item_indent_match = re.search(r'\n( +)"', array_content)
|
|
84
84
|
item_indent = item_indent_match.group(1) if item_indent_match else " "
|
|
85
|
-
|
|
85
|
+
# Strip trailing comma to avoid double comma when the list
|
|
86
|
+
# already has a trailing comma before the closing bracket
|
|
87
|
+
stripped = array_content.rstrip()
|
|
88
|
+
stripped = stripped.rstrip(",")
|
|
89
|
+
new_array_content = stripped + f',\n{item_indent}"rlsbl"\n'
|
|
86
90
|
else:
|
|
87
91
|
# Single-line
|
|
88
92
|
if array_content.strip():
|
|
89
|
-
|
|
93
|
+
stripped_sl = array_content.rstrip().rstrip(",")
|
|
94
|
+
new_array_content = stripped_sl + ', "rlsbl"'
|
|
90
95
|
else:
|
|
91
96
|
new_array_content = '"rlsbl"'
|
|
92
97
|
new_field = prefix + new_array_content + "]"
|
|
@@ -8,15 +8,19 @@ set -euo pipefail
|
|
|
8
8
|
echo "Running pre-release checks..."
|
|
9
9
|
|
|
10
10
|
if [ -f go.mod ]; then
|
|
11
|
-
echo "
|
|
11
|
+
echo " Go: vet + build + test"
|
|
12
12
|
go vet ./...
|
|
13
13
|
go build ./...
|
|
14
14
|
go test ./... -race -short -count=1
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
fi
|
|
16
|
+
|
|
17
|
+
if [ -f package.json ] && node -e "process.exit(require('./package.json').scripts?.test ? 0 : 1)" 2>/dev/null; then
|
|
18
|
+
echo " npm: test"
|
|
17
19
|
npm test
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
fi
|
|
21
|
+
|
|
22
|
+
if [ -f pyproject.toml ]; then
|
|
23
|
+
echo " Python: pytest"
|
|
20
24
|
if command -v uv &>/dev/null; then
|
|
21
25
|
uv run pytest
|
|
22
26
|
elif command -v pytest &>/dev/null; then
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|