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 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 `check-prs`, `record-gif`, and `pre-push-check` functionality is provided as built-in subcommands (`rlsbl check-prs`, `rlsbl record-gif`, `rlsbl pre-push-check`) rather than scaffolded scripts.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rlsbl",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Release orchestration and project scaffolding for npm and PyPI",
5
5
  "license": "MIT",
6
6
  "bin": {
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
- "check-prs", "pre-push-check", "record-gif")
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 ("check-prs", "pre-push-check", "record-gif"):
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
- # Files where existing content is preserved and template sections are appended
16
- APPENDABLE = {"CLAUDE.md"}
17
- APPEND_MARKER = "rlsbl"
18
-
19
- # Files where missing entries from the template are merged into the existing file
20
- MERGEABLE = {".gitignore"}
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
- Skips existing files unless force is True, with special handling:
92
- - APPENDABLE files: append template sections if the marker is not already present
93
- - MERGEABLE files: merge missing entries from the template into the existing file
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
- # When file exists and force is not set, use context-aware handling
116
- if os.path.exists(target) and not force:
117
- basename = os.path.basename(target)
118
-
119
- # In --update mode, overwrite managed files only if not customized
120
- if update and target in UPDATABLE:
121
- current_hash = file_hash(target)
122
- stored_hash = existing_hashes.get(target)
123
- if stored_hash and current_hash == stored_hash:
124
- # File matches stored hash -- not customized, safe to overwrite
125
- with open(template_path, "r", encoding="utf-8") as f:
126
- raw = f.read()
127
- content, unreplaced = process_template(raw, vars_dict)
128
- target_dir = os.path.dirname(target)
129
- if target_dir and target_dir != ".":
130
- os.makedirs(target_dir, exist_ok=True)
131
- with open(target, "w", encoding="utf-8") as f:
132
- f.write(content)
133
- new_hashes[target] = file_hash(target)
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
- if basename in APPENDABLE:
145
- with open(target, "r", encoding="utf-8") as f:
146
- existing = f.read()
147
- if APPEND_MARKER in existing:
148
- skipped.append(target + " (already has rlsbl section)")
149
- continue
150
- # Append only the ## sections, stripping the top-level # heading
151
- with open(template_path, "r", encoding="utf-8") as f:
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
- existing = f.read()
169
- with open(template_path, "r", encoding="utf-8") as f:
170
- raw = f.read()
171
- content, _ = process_template(raw, vars_dict)
172
- existing_lines = {
173
- line.strip() for line in existing.split("\n") if line.strip()
174
- }
175
- # Normalize by stripping trailing slashes so e.g.
176
- # "*.egg-info/" matches "*.egg-info" and vice versa.
177
- existing_normalized = {
178
- line.rstrip("/") for line in existing_lines
179
- }
180
- new_lines = [
181
- line.strip() for line in content.split("\n") if line.strip()
182
- ]
183
- # Only merge non-comment entries that are missing from the existing file
184
- missing = [
185
- line for line in new_lines
186
- if line.rstrip("/") not in existing_normalized
187
- and not line.startswith("#")
188
- ]
189
- if missing:
190
- with open(target, "a", encoding="utf-8") as f:
191
- f.write("\n# Added by rlsbl\n" + "\n".join(missing) + "\n")
192
- created.append(f"{target} (merged {len(missing)} entries)")
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 + " (all entries present)")
195
- continue
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
- with open(template_path, "r", encoding="utf-8") as f:
201
- raw = f.read()
202
- content, unreplaced = process_template(raw, vars_dict)
203
-
204
- # Ensure parent directory exists
205
- target_dir = os.path.dirname(target)
206
- if target_dir and target_dir != ".":
207
- os.makedirs(target_dir, exist_ok=True)
208
-
209
- with open(target, "w", encoding="utf-8") as f:
210
- f.write(content)
211
- new_hashes[target] = file_hash(target)
212
- created.append(target)
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 unreplaced:
215
- warnings.append(f"{target}: unreplaced vars: {', '.join(unreplaced)}")
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 summary
271
- if created:
272
- print("Created:")
273
- for f in created:
274
- print(f" {f}")
275
-
276
- if skipped:
277
- print("Skipped (already exist, use --update to refresh managed files or --force to overwrite all):")
278
- for f in skipped:
279
- print(f" {f}")
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(s.startswith(ci_path) for s in skipped):
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,15 +1,3 @@
1
1
  {
2
- "hooks": {
3
- "SessionStart": [
4
- {
5
- "matcher": "",
6
- "hooks": [
7
- {
8
- "type": "command",
9
- "command": "rlsbl check-prs"
10
- }
11
- ]
12
- }
13
- ]
14
- }
2
+ "hooks": {}
15
3
  }
@@ -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)