@@ -69,12 +69,12 @@ def short_sha(git_root):
6969 return r .stdout .strip ()
7070
7171
72- def has_staged_changes (git_root ):
73- """Return True if there are staged changes in the index."""
74- r = subprocess . run (
75- [ "git" , "diff" , "--cached" , "--name-only" ],
76- cwd = git_root , capture_output = True , text = True ,
77- )
72+ def has_staged_changes (git_root , paths = None ):
73+ """Return True if there are staged changes in the index, optionally scoped to paths ."""
74+ cmd = [ "git" , "diff" , "--cached" , "--name-only" ]
75+ if paths :
76+ cmd += [ "--" ] + list ( paths )
77+ r = subprocess . run ( cmd , cwd = git_root , capture_output = True , text = True )
7878 return bool (r .stdout .strip ())
7979
8080
@@ -192,11 +192,14 @@ def cmd_prepare(args, git_root):
192192 version = args .version
193193 release_branch = args .release_branch
194194 gradle_cmd = args .gradle_cmd
195+ rc_suffix = f" RC{ args .rc_number } " if args .rc_number > 1 else ""
196+ version_label = f"v{ version } { rc_suffix } "
195197
196198 version_dir = git_root / "changelog" / f"v{ version } "
197199 unreleased_dir = git_root / "changelog" / "unreleased"
200+ version_summary = f"changelog/v{ version } /version-summary.md"
198201
199- print (f"\n === Changelog prepare for v { version } on { release_branch } ===" )
202+ print (f"\n === Changelog prepare for { version_label } on { release_branch } ===" )
200203 if dry_run :
201204 print (" (dry-run — no changes will be made)\n " )
202205 else :
@@ -248,26 +251,41 @@ def cmd_prepare(args, git_root):
248251 shutil .copy2 (src , dst )
249252 src .unlink ()
250253
251- print (f"\n [3] Running logchangeGenerate and stripping [unreleased] block" )
252- run_logchange_generate (gradle_cmd , git_root , dry_run = dry_run )
253-
254254 if do_commit :
255- print (f"\n [4] Staging changelog/ and CHANGELOG.md" )
255+ # Commit A: changelog/ YAML files only (version-summary.md is stale until generate runs)
256+ print (f"\n [3] Staging changelog/ entries for { version_label } (commit A)" )
256257 ensure_unreleased_gitkeep (git_root , dry_run = dry_run )
257- git (["add" , "changelog" , "CHANGELOG.md" ], cwd = git_root , dry_run = dry_run )
258+ git (["add" , "changelog" ], cwd = git_root , dry_run = dry_run )
259+ # Unstage version-summary.md — it is stale until logchangeGenerate runs
260+ git (["restore" , "--staged" , version_summary ], cwd = git_root , dry_run = dry_run , check = False )
261+
262+ if not dry_run and not has_staged_changes (git_root , ["changelog/" ]):
263+ print ("\n Nothing staged — changelog entries are already up to date." )
264+ else :
265+ msg_a = f"Changelog entries for { version_label } "
266+ print (f"\n [4] Committing: { msg_a !r} " )
267+ git (["commit" , "-m" , msg_a ], cwd = git_root , dry_run = dry_run )
258268
259- if not dry_run and not has_staged_changes ( git_root ):
260- print (" \n Nothing staged — changelog is already up to date. " )
261- return
269+ # Commit B: regenerate, then stage CHANGELOG.md + version-summary.md
270+ print (f" \n [5] Running logchangeGenerate and stripping [unreleased] block " )
271+ run_logchange_generate ( gradle_cmd , git_root , dry_run = dry_run )
262272
263- msg = f"Changelog prepare for v{ version } "
264- print (f"\n [5] Committing: { msg !r} " )
265- git (["commit" , "-m" , msg ], cwd = git_root , dry_run = dry_run )
273+ print (f"\n [6] Staging CHANGELOG.md and version-summary.md (commit B)" )
274+ git (["add" , "CHANGELOG.md" , version_summary ], cwd = git_root , dry_run = dry_run )
275+
276+ if not dry_run and not has_staged_changes (git_root , ["CHANGELOG.md" , version_summary ]):
277+ print (" Nothing staged — CHANGELOG.md already up to date." )
278+ else :
279+ msg_b = f"Regenerate CHANGELOG.md for { version_label } "
280+ print (f"\n [7] Committing: { msg_b !r} " )
281+ git (["commit" , "-m" , msg_b ], cwd = git_root , dry_run = dry_run )
266282
267283 if not dry_run :
268- print (f"\n Done. Commit { short_sha (git_root )} on { release_branch } ." )
284+ print (f"\n Done. Last commit { short_sha (git_root )} on { release_branch } ." )
269285 print (f"Push with: git push { args .git_remote } { release_branch } " )
270286 else :
287+ print (f"\n [3] Running logchangeGenerate and stripping [unreleased] block" )
288+ run_logchange_generate (gradle_cmd , git_root , dry_run = dry_run )
271289 print (f"\n Review the changes above, then run with --commit to stage and commit." )
272290 if not dry_run :
273291 print (f" git diff changelog/ CHANGELOG.md" )
@@ -286,6 +304,7 @@ def cmd_forward_port(args, git_root):
286304 version = args .version
287305 release_branch = args .release_branch
288306 stable_branch = args .stable_branch
307+ latest_lts_stable_branch = args .latest_lts_stable_branch
289308 release_date_str = args .release_date or date .today ().isoformat ()
290309
291310 version_dir = git_root / "changelog" / f"v{ version } "
@@ -295,6 +314,8 @@ def cmd_forward_port(args, git_root):
295314 print (f" release_branch : { release_branch } " )
296315 print (f" stable_branch : { stable_branch } " )
297316 print (f" also targets : main" )
317+ if latest_lts_stable_branch :
318+ print (f" lts also targets: { latest_lts_stable_branch } " )
298319 if dry_run :
299320 print (" (dry-run — no changes will be made)" )
300321 elif not do_push :
@@ -315,83 +336,139 @@ def cmd_forward_port(args, git_root):
315336 if not dry_run :
316337 date_file .write_text (release_date_str + "\n " , encoding = "utf-8" )
317338
318- # Step 2: regenerate CHANGELOG.md (now with the release date)
319- print (f"\n [2] Running logchangeGenerate (with release date { release_date_str } )" )
320- run_logchange_generate (args .gradle_cmd , git_root , dry_run = dry_run )
339+ version_summary = f"changelog/v{ version } /version-summary.md"
321340
322- # Step 3: stage and commit
323- print (f"\n [3] Staging and committing to { release_branch } " )
324- git (["add" , "changelog" , "CHANGELOG.md " ], cwd = git_root , dry_run = dry_run )
341+ # Step 2: commit A — release-date.txt only (before generate)
342+ print (f"\n [2] Committing release-date.txt to { release_branch } (commit A) " )
343+ git (["add" , f "changelog/v { version } /release-date.txt " ], cwd = git_root , dry_run = dry_run )
325344
326- if not dry_run and not has_staged_changes (git_root ):
327- print (" Nothing staged — release-date.txt and CHANGELOG.md were already up to date." )
345+ if not dry_run and not has_staged_changes (git_root , [ f"changelog/v { version } /release-date.txt" ] ):
346+ print (" Nothing staged — release-date.txt was already up to date." )
328347 else :
329- msg = f"Set release date { release_date_str } and regenerate CHANGELOG.md for v{ version } "
330- print (f" Committing: { msg !r} " )
331- git (["commit" , "-m" , msg ], cwd = git_root , dry_run = dry_run )
332-
333- # Step 4: find commits on release_branch not yet on stable_branch that
334- # touch changelog/ or CHANGELOG.md. --cherry-pick with the
335- # symmetric-difference range (three dots) omits commits whose patch
336- # is already present on the target, making forward-port idempotent
337- # when re-run after an initial no-push review run.
338- print (f"\n [4] Finding changelog commits on { release_branch } not yet on { stable_branch } " )
339- result = subprocess .run (
340- ["git" , "log" , "--oneline" , "--reverse" ,
341- "--cherry-pick" , "--right-only" ,
342- f"{ stable_branch } ...{ release_branch } " ,
343- "--" , "changelog/" , "CHANGELOG.md" ],
344- cwd = git_root , capture_output = True , text = True , check = True ,
345- )
346- commits = [line .split ()[0 ] for line in result .stdout .strip ().splitlines () if line ]
348+ msg_a = f"Set release date { release_date_str } for v{ version } "
349+ print (f" Committing: { msg_a !r} " )
350+ git (["commit" , "-m" , msg_a ], cwd = git_root , dry_run = dry_run )
351+
352+ # Step 3: commit B — regenerate, then stage CHANGELOG.md + version-summary.md
353+ print (f"\n [3] Running logchangeGenerate (with release date { release_date_str } )" )
354+ run_logchange_generate (args .gradle_cmd , git_root , dry_run = dry_run )
347355
348- if not commits :
349- print (f" No changelog commits to forward-port — { stable_branch } is already up to date." )
356+ print (f"\n [3b] Staging CHANGELOG.md and version-summary.md to { release_branch } (commit B)" )
357+ git (["add" , "CHANGELOG.md" , version_summary ], cwd = git_root , dry_run = dry_run )
358+
359+ if not dry_run and not has_staged_changes (git_root , ["CHANGELOG.md" , version_summary ]):
360+ print (" Nothing staged — CHANGELOG.md already up to date." )
350361 else :
351- print (f" Found { len (commits )} commit(s) to cherry-pick: { ', ' .join (commits )} " )
352-
353- for target in [stable_branch , "main" ]:
354- print (f"\n [5] Cherry-picking { len (commits )} commit(s) to { target } " )
355- git (["checkout" , target ], cwd = git_root , dry_run = dry_run )
356- for sha in commits :
357- # Commits that touch changelog/unreleased/ are deletions of files
358- # that exist under different names on stable/main — use -X ours so
359- # git keeps the target branch's own unreleased entries rather than
360- # trying to delete them. Commits that only add to the version
361- # folder or update CHANGELOG.md are clean additions; cherry-pick
362- # them plainly so real conflicts are not silently discarded.
363- if commit_touches_unreleased (sha , git_root ):
364- cp_args = ["cherry-pick" , "-X" , "ours" , "-X" , "no-renames" , sha ]
365- else :
366- cp_args = ["cherry-pick" , sha ]
367- try :
368- git (cp_args , cwd = git_root , dry_run = dry_run )
369- except subprocess .CalledProcessError :
370- print (f"\n Error: cherry-pick of { sha } failed on { target } ." ,
371- file = sys .stderr )
372- print (" Resolve the conflict, then run: git cherry-pick --continue" ,
373- file = sys .stderr )
374- print (" Or abort with: git cherry-pick --abort" ,
375- file = sys .stderr )
376- sys .exit (1 )
377-
378- # Ensure unreleased/ folder exists for contributors after cherry-picks
379- ensure_unreleased_gitkeep (git_root , dry_run = dry_run )
380- git (["add" , "changelog/unreleased/.gitkeep" ], cwd = git_root , dry_run = dry_run )
381- if not dry_run and has_staged_changes (git_root ):
382- git (["commit" , "-m" , f"Ensure changelog/unreleased/ folder exists on { target } " ],
362+ msg_b = f"Regenerate CHANGELOG.md for v{ version } "
363+ print (f" Committing: { msg_b !r} " )
364+ git (["commit" , "-m" , msg_b ], cwd = git_root , dry_run = dry_run )
365+
366+ # Steps 4+5: for each target branch, find commits on release_branch not yet on
367+ # that target, cherry-pick them, then regenerate CHANGELOG.md fresh.
368+ # CHANGELOG.md is never cherry-picked — it is always regenerated so
369+ # each branch gets a correct full-history version (avoids cross-major
370+ # conflicts). version-summary.md is also excluded from cherry-picks
371+ # for the same reason; it is regenerated alongside CHANGELOG.md.
372+ # --cherry-pick with the symmetric-difference range (three dots) omits
373+ # commits whose patch is already present on the target, making
374+ # forward-port idempotent when re-run after an initial no-push run.
375+ targets = [stable_branch , "main" ]
376+ if latest_lts_stable_branch :
377+ targets .append (latest_lts_stable_branch )
378+
379+ for target in targets :
380+ print (f"\n [4] Finding changelog/ commits on { release_branch } not yet on { target } " )
381+ if dry_run :
382+ print (f" (dry-run) would run: git log --cherry-pick --right-only { target } ...{ release_branch } -- changelog/ :(exclude)changelog/*/version-summary.md" )
383+ commits = []
384+ else :
385+ result = subprocess .run (
386+ ["git" , "log" , "--oneline" , "--reverse" ,
387+ "--cherry-pick" , "--right-only" ,
388+ f"{ target } ...{ release_branch } " ,
389+ "--" , "changelog/" , ":(exclude)changelog/*/version-summary.md" ],
390+ cwd = git_root , capture_output = True , text = True , check = True ,
391+ )
392+ commits = [line .split ()[0 ] for line in result .stdout .strip ().splitlines () if line ]
393+
394+ if not commits :
395+ print (f" No changelog commits to forward-port — { target } is already up to date." )
396+ else :
397+ print (f" Found { len (commits )} commit(s) to cherry-pick: { ', ' .join (commits )} " )
398+
399+ print (f"\n [5] Cherry-picking { len (commits )} commit(s) to { target } " )
400+ git (["checkout" , target ], cwd = git_root , dry_run = dry_run )
401+ for sha in commits :
402+ # Commits that touch changelog/unreleased/ are deletions of files
403+ # that exist under different names on stable/main — use -X ours so
404+ # git keeps the target branch's own unreleased entries rather than
405+ # trying to delete them. Commits that only add to the version
406+ # folder are clean additions and cherry-pick without strategy options.
407+ if commit_touches_unreleased (sha , git_root ):
408+ cp_args = ["cherry-pick" , "-X" , "ours" , "-X" , "no-renames" , sha ]
409+ else :
410+ cp_args = ["cherry-pick" , sha ]
411+ try :
412+ git (cp_args , cwd = git_root , dry_run = dry_run )
413+ except subprocess .CalledProcessError :
414+ print (f"\n Error: cherry-pick of { sha } failed on { target } ." ,
415+ file = sys .stderr )
416+ print (" Resolve the conflict, then run: git cherry-pick --continue" ,
417+ file = sys .stderr )
418+ print (" Or abort with: git cherry-pick --abort" ,
419+ file = sys .stderr )
420+ sys .exit (1 )
421+
422+ # Ensure unreleased/ folder exists for contributors after cherry-picks
423+ ensure_unreleased_gitkeep (git_root , dry_run = dry_run )
424+ git (["add" , "changelog/unreleased/.gitkeep" ], cwd = git_root , dry_run = dry_run )
425+ if not dry_run and has_staged_changes (git_root , ["changelog/unreleased/.gitkeep" ]):
426+ git (["commit" , "-m" , f"Ensure changelog/unreleased/ folder exists on { target } " ],
427+ cwd = git_root , dry_run = dry_run )
428+
429+ # Remove any v{version} YAML files that still linger in unreleased/ on this
430+ # target branch. Cherry-pick uses -X ours, so a "we modified / they deleted"
431+ # conflict silently keeps the local copy; this explicit pass fixes that.
432+ version_dir = git_root / f"changelog/v{ version } "
433+ stale = (
434+ [
435+ git_root / "changelog/unreleased" / f .name
436+ for f in version_dir .glob ("*.yml" )
437+ if (git_root / "changelog/unreleased" / f .name ).exists ()
438+ ]
439+ if not dry_run
440+ else []
441+ )
442+ if stale :
443+ rel = [str (p .relative_to (git_root )) for p in stale ]
444+ print (f"\n Removing { len (stale )} stale unreleased file(s) from { target } : "
445+ f"{ ', ' .join (p .name for p in stale )} " )
446+ git (["rm" , "--force" ] + rel , cwd = git_root , dry_run = dry_run )
447+ if not dry_run and has_staged_changes (git_root , ["changelog/unreleased/" ]):
448+ git (["commit" , "-m" , f"Remove v{ version } entries from unreleased/ on { target } " ],
383449 cwd = git_root , dry_run = dry_run )
384450
451+ # Regenerate CHANGELOG.md and version-summary.md fresh on this branch.
452+ print (f"\n Regenerating CHANGELOG.md on { target } " )
453+ run_logchange_generate (args .gradle_cmd , git_root , dry_run = dry_run )
454+ git (["add" , "CHANGELOG.md" , version_summary ], cwd = git_root , dry_run = dry_run )
455+ if not dry_run and has_staged_changes (git_root , ["CHANGELOG.md" , version_summary ]):
456+ git (["commit" , "-m" , f"Regenerate CHANGELOG.md with v{ version } entries on { target } " ],
457+ cwd = git_root , dry_run = dry_run )
458+
385459 # Step 6: push (optional)
386460 if do_push :
387461 remote = args .git_remote
388- print (f"\n [6] Pushing { release_branch } , { stable_branch } , main to { remote } " )
389- for branch in [release_branch , stable_branch , "main" ]:
462+ branches_to_push = [release_branch , stable_branch , "main" ]
463+ if latest_lts_stable_branch :
464+ branches_to_push .append (latest_lts_stable_branch )
465+ print (f"\n [6] Pushing { ', ' .join (branches_to_push )} to { remote } " )
466+ for branch in branches_to_push :
390467 git_push (branch , remote , cwd = git_root , dry_run = dry_run )
391468 else :
392469 print (f"\n Cherry-picks done. Review, then re-run with --push to push all branches." )
393470 if not dry_run :
394- print (f" git log --oneline { stable_branch } -- changelog/ CHANGELOG.md " )
471+ print (f" git log --oneline { stable_branch } -- changelog/" )
395472
396473 if dry_run :
397474 print ("\n Dry-run complete — no changes made." )
@@ -434,6 +511,8 @@ def build_parser():
434511 formatter_class = fmt )
435512 p .add_argument ("--commit" , action = "store_true" ,
436513 help = "Stage and commit the result (default: leave uncommitted for review)" )
514+ p .add_argument ("--rc-number" , type = int , default = 1 ,
515+ help = "RC number (default: 1). RC2+ appends ' RC<n>' to commit messages." )
437516 p .add_argument ("--skip-validation" , action = "store_true" ,
438517 help = "Skip pre-flight YAML validation of changelog/unreleased/ files" )
439518 p .set_defaults (func = cmd_prepare )
@@ -448,6 +527,8 @@ def build_parser():
448527 formatter_class = fmt )
449528 fp .add_argument ("--stable-branch" , required = True ,
450529 help = "Stable branch, e.g. branch_10x" )
530+ fp .add_argument ("--latest-lts-stable-branch" ,
531+ help = "Also forward-port to this LTS stable branch, e.g. branch_9x (LTS releases only)" )
451532 fp .add_argument ("--release-date" ,
452533 help = "Release date YYYY-MM-DD (default: today)" )
453534 fp .add_argument ("--push" , action = "store_true" ,
0 commit comments