blob: 646f04306a8e349856b1f94e883ce03892f429b3 [file]
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Changelog release helper for Apache Solr release managers.
Normally invoked by the Release Wizard; see dev-docs/changelog.adoc for details.
Requires git 2.23 or later (git restore).
"""
import argparse
import re
import shutil
import subprocess
import sys
from datetime import date
from pathlib import Path
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def find_git_root() -> Path:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True, text=True, check=True,
)
return Path(result.stdout.strip())
def run(cmd, *, cwd, dry_run=False, check=True, capture=False):
"""Print and optionally execute a shell command."""
print(f" $ {' '.join(str(c) for c in cmd)}")
if dry_run:
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
return subprocess.run(
cmd, cwd=cwd, capture_output=capture, text=True, check=check,
)
def git(args, *, cwd, dry_run=False, check=True, capture=False):
return run(["git"] + args, cwd=cwd, dry_run=dry_run, check=check, capture=capture)
def git_push(branch, remote, *, cwd, dry_run=False):
git(["checkout", branch], cwd=cwd, dry_run=dry_run)
git(["push", remote, branch], cwd=cwd, dry_run=dry_run)
def short_sha(git_root):
r = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
cwd=git_root, capture_output=True, text=True, check=True,
)
return r.stdout.strip()
def has_staged_changes(git_root, paths=None):
"""Return True if there are staged changes in the index, optionally scoped to paths."""
cmd = ["git", "diff", "--cached", "--name-only"]
if paths:
cmd += ["--"] + list(paths)
r = subprocess.run(cmd, cwd=git_root, capture_output=True, text=True)
return bool(r.stdout.strip())
def git_paths(args, git_root):
"""Run a git command that lists paths, NUL-separated so non-ASCII names are not quoted."""
r = subprocess.run(
["git"] + args + ["-z"],
cwd=git_root, capture_output=True, text=True, check=True,
)
return [p for p in r.stdout.split("\0") if p]
def commit_touches_unreleased(sha, git_root):
"""Return True if the given commit modifies any file under changelog/unreleased/."""
files = git_paths(["diff-tree", "--no-commit-id", "-r", "--name-only", sha], git_root)
return any(f.startswith("changelog/unreleased/") for f in files)
def unmerged_paths(git_root):
return git_paths(["diff", "--name-only", "--diff-filter=U"], git_root)
def recover_cherry_pick(git_root, version, dry_run=False):
"""Try to finish a cherry-pick that stopped. Return True if it was resolved.
Handles two cases: conflicts confined to changelog/unreleased/ entries that
are being released (a counterpart exists in changelog/v{version}/, so the
unreleased entry is removed), and a pick that is empty because its changes
are already on this branch. Any other conflict is left for the user.
"""
if dry_run:
return False
in_progress = subprocess.run(
["git", "rev-parse", "-q", "--verify", "CHERRY_PICK_HEAD"],
cwd=git_root, capture_output=True,
).returncode == 0
if not in_progress:
return False
conflicts = unmerged_paths(git_root)
version_dir = git_root / "changelog" / f"v{version}"
for p in conflicts:
if not p.startswith("changelog/unreleased/"):
return False
if not (version_dir / Path(p).name).exists():
print(f" {p} conflicts but has no counterpart in {version_dir.relative_to(git_root)}/ — "
f"not removing it.")
return False
if conflicts:
print(f" Removing {len(conflicts)} conflicting unreleased file(s): "
f"{', '.join(Path(p).name for p in conflicts)}")
git(["rm", "--quiet", "--"] + conflicts, cwd=git_root)
if has_staged_changes(git_root):
git(["-c", "core.editor=true", "cherry-pick", "--continue"], cwd=git_root)
else:
print(" Nothing left to apply — skipping.")
git(["cherry-pick", "--skip"], cwd=git_root)
return True
def require_clean_tree(git_root):
"""Exit unless the working tree has no uncommitted changes to tracked files."""
r = subprocess.run(
["git", "status", "--porcelain", "--untracked-files=no"],
cwd=git_root, capture_output=True, text=True, check=True,
)
if r.stdout.strip():
print("Error: the working tree has uncommitted changes. Commit or stash them first:",
file=sys.stderr)
print(r.stdout, file=sys.stderr, end="")
sys.exit(1)
def discard_generated_changelog_files(git_root, dry_run=False):
"""Drop what logchangeGenerate left under changelog/ once the wanted files are committed.
Generation rewrites every version's version-summary.md; a modified or
untracked copy for another version would block the next checkout.
"""
git(["restore", "changelog/"], cwd=git_root, dry_run=dry_run)
git(["clean", "--force", "--", "changelog/*/version-summary.md"], cwd=git_root, dry_run=dry_run)
def strip_unreleased_block(changelog_path: Path, dry_run=False):
"""Remove the [unreleased] block that logchangeGenerate emits.
CHANGELOG.md uses logchange's setext-style headings, e.g.:
[unreleased]
------------
### Added ...
...content...
[10.1.0]
--------
We strip from the ``[unreleased]`` line up to (but not including) the next
versioned heading, identified by a ``[`` at the start of a line that is not
``[unreleased]``.
"""
if not changelog_path.exists():
print(" (CHANGELOG.md not found — skipping strip)")
return
text = changelog_path.read_text(encoding="utf-8")
cleaned = re.sub(
r'^\[unreleased\]\n(?:.*\n)*?(?=^\[(?!unreleased\]))',
'',
text,
flags=re.MULTILINE,
)
if cleaned == text:
print(" (no [unreleased] block found in CHANGELOG.md — nothing stripped)")
return
print(" Stripped [unreleased] block from CHANGELOG.md")
if not dry_run:
changelog_path.write_text(cleaned, encoding="utf-8")
def validate_unreleased_yamls(git_root, dry_run=False):
"""Run validate-changelog-yaml.py over changelog/unreleased/.
Passes the folder path to the validator, which handles file discovery itself.
Locates the validator relative to this script so it works regardless of cwd.
If the validator cannot be found, logs a warning and returns True (proceed).
Returns True if all files are valid (or nothing to validate), False otherwise.
"""
unreleased_dir = git_root / "changelog" / "unreleased"
if not unreleased_dir.exists():
return True
validator = Path(__file__).parent / "validate-changelog-yaml.py"
if not validator.exists():
print(f" Warning: validator not found at {validator} — skipping YAML validation")
return True
print(f"\n[0] Validating changelog/unreleased/ with {validator.name}")
if dry_run:
print(f" (dry-run) would run: {validator.name} {unreleased_dir}")
return True
result = subprocess.run(
[sys.executable, str(validator), str(unreleased_dir)],
cwd=git_root,
)
if result.returncode != 0:
print("\nError: changelog YAML validation failed — please fix the issues and run again.",
file=sys.stderr)
return False
return True
def ensure_unreleased_gitkeep(git_root, dry_run=False):
"""Ensure changelog/unreleased/.gitkeep exists and is staged.
After a release the unreleased/ folder may be absent or untracked.
Committing a .gitkeep guarantees contributors always find the folder.
"""
gitkeep = git_root / "changelog" / "unreleased" / ".gitkeep"
if not gitkeep.exists():
print(f" Creating {gitkeep.relative_to(git_root)}")
if not dry_run:
gitkeep.parent.mkdir(parents=True, exist_ok=True)
gitkeep.touch()
def run_logchange_generate(gradle_cmd, git_root, dry_run=False):
run([gradle_cmd, "logchangeGenerate"], cwd=git_root, dry_run=dry_run)
strip_unreleased_block(git_root / "CHANGELOG.md", dry_run=dry_run)
# logchangeGenerate may create/touch changelog/unreleased/version-summary.md;
# remove it so it doesn't clutter the working tree.
summary = git_root / "changelog" / "unreleased" / "version-summary.md"
if summary.exists():
print(f" Removing {summary.relative_to(git_root)}")
if not dry_run:
summary.unlink()
# ---------------------------------------------------------------------------
# prepare
# ---------------------------------------------------------------------------
def cmd_prepare(args, git_root):
dry_run = args.dry_run
do_commit = args.commit
version = args.version
release_branch = args.release_branch
gradle_cmd = args.gradle_cmd
rc_suffix = f" RC{args.rc_number}" if args.rc_number > 1 else ""
version_label = f"v{version}{rc_suffix}"
version_dir = git_root / "changelog" / f"v{version}"
unreleased_dir = git_root / "changelog" / "unreleased"
version_summary = f"changelog/v{version}/version-summary.md"
print(f"\n=== Changelog prepare for {version_label} on {release_branch} ===")
if dry_run:
print(" (dry-run — no changes will be made)\n")
else:
print()
print(f"[1] Checking out {release_branch}")
git(["checkout", release_branch], cwd=git_root, dry_run=dry_run)
if not args.skip_validation:
if not validate_unreleased_yamls(git_root, dry_run=dry_run):
sys.exit(1)
# RC1 vs RC2+ detection is done on the filesystem after checkout.
# In dry-run mode the checkout is skipped, so warn the user the detection
# may reflect the current branch rather than the release branch.
if dry_run and not version_dir.exists():
print(f" (dry-run: branch not actually checked out; RC path detection "
f"is based on current working tree and may be inaccurate)")
if not version_dir.exists():
# ------------------------------------------------------------------ #
# RC1 path: version folder does not yet exist #
# ------------------------------------------------------------------ #
print(f"\n[2] RC1 path — running logchangeRelease --releaseDate none --versionToRelease {version}")
run([gradle_cmd, "logchangeRelease", "--releaseDate", "none", "--versionToRelease", version],
cwd=git_root, dry_run=dry_run)
else:
# ------------------------------------------------------------------ #
# RC2+ path: version folder already exists, move only new entries #
# ------------------------------------------------------------------ #
print(f"\n[2] RC2+ path — copying new unreleased entries to {version_dir.name}/")
if not unreleased_dir.exists():
print(" changelog/unreleased/ does not exist — nothing to move.")
else:
existing = {f.name for f in version_dir.iterdir() if f.is_file()}
new_files = [
f for f in unreleased_dir.iterdir()
if f.is_file() and f.name not in existing
]
if not new_files:
print(" No new entries in changelog/unreleased/ — nothing to move.")
else:
for src in sorted(new_files):
dst = version_dir / src.name
print(f" Moving {src.name} → {version_dir.name}/")
if not dry_run:
shutil.copy2(src, dst)
src.unlink()
if do_commit:
# Commit A: changelog/ YAML files only (version-summary.md is stale until generate runs)
print(f"\n[3] Staging changelog/ entries for {version_label} (commit A)")
ensure_unreleased_gitkeep(git_root, dry_run=dry_run)
git(["add", "changelog"], cwd=git_root, dry_run=dry_run)
# Unstage version-summary.md — it is stale until logchangeGenerate runs
git(["restore", "--staged", version_summary], cwd=git_root, dry_run=dry_run, check=False)
if not dry_run and not has_staged_changes(git_root, ["changelog/"]):
print("\nNothing staged — changelog entries are already up to date.")
else:
msg_a = f"Changelog entries for {version_label}"
print(f"\n[4] Committing: {msg_a!r}")
git(["commit", "-m", msg_a], cwd=git_root, dry_run=dry_run)
# Commit B: regenerate, then stage CHANGELOG.md + version-summary.md
print(f"\n[5] Running logchangeGenerate and stripping [unreleased] block")
run_logchange_generate(gradle_cmd, git_root, dry_run=dry_run)
print(f"\n[6] Staging CHANGELOG.md and version-summary.md (commit B)")
git(["add", "CHANGELOG.md", version_summary], cwd=git_root, dry_run=dry_run)
if not dry_run and not has_staged_changes(git_root, ["CHANGELOG.md", version_summary]):
print(" Nothing staged — CHANGELOG.md already up to date.")
else:
msg_b = f"Regenerate CHANGELOG.md for {version_label}"
print(f"\n[7] Committing: {msg_b!r}")
git(["commit", "-m", msg_b], cwd=git_root, dry_run=dry_run)
if not dry_run:
print(f"\nDone. Last commit {short_sha(git_root)} on {release_branch}.")
print(f"Push with: git push {args.git_remote} {release_branch}")
else:
print(f"\n[3] Running logchangeGenerate and stripping [unreleased] block")
run_logchange_generate(gradle_cmd, git_root, dry_run=dry_run)
print(f"\nReview the changes above, then run with --commit to stage and commit.")
if not dry_run:
print(f" git diff changelog/ CHANGELOG.md")
if dry_run:
print("\nDry-run complete — no changes made.")
# ---------------------------------------------------------------------------
# forward-port
# ---------------------------------------------------------------------------
def cmd_forward_port(args, git_root):
dry_run = args.dry_run
do_push = args.push
version = args.version
release_branch = args.release_branch
stable_branch = args.stable_branch
latest_lts_stable_branch = args.latest_lts_stable_branch
release_date_str = args.release_date or date.today().isoformat()
version_dir = git_root / "changelog" / f"v{version}"
print(f"\n=== Changelog forward-port for v{version} ===")
print(f" Release date : {release_date_str}")
print(f" release_branch : {release_branch}")
print(f" stable_branch : {stable_branch}")
print(f" also targets : main")
if latest_lts_stable_branch:
print(f" lts also targets: {latest_lts_stable_branch}")
if dry_run:
print(" (dry-run — no changes will be made)")
elif not do_push:
print(" (push disabled — run with --push when ready)")
print()
# The script checks out several branches and discards generated files with
# git restore, so refuse to run on top of uncommitted work.
require_clean_tree(git_root)
# Step 1: checkout and update release branch, write release-date.txt
print(f"[1] Checking out and updating {release_branch}, writing release-date.txt")
git(["checkout", release_branch], cwd=git_root, dry_run=dry_run)
git(["pull", "--ff-only", args.git_remote, release_branch], cwd=git_root, dry_run=dry_run)
if not dry_run and not version_dir.exists():
print(f"Error: version folder {version_dir} does not exist. "
f"Run 'prepare' first.", file=sys.stderr)
sys.exit(1)
date_file = version_dir / "release-date.txt"
print(f" Writing {date_file.relative_to(git_root)}: {release_date_str}")
if not dry_run:
date_file.write_text(release_date_str + "\n", encoding="utf-8")
version_summary = f"changelog/v{version}/version-summary.md"
# Step 2: commit A — release-date.txt only (before generate)
print(f"\n[2] Committing release-date.txt to {release_branch} (commit A)")
git(["add", f"changelog/v{version}/release-date.txt"], cwd=git_root, dry_run=dry_run)
if not dry_run and not has_staged_changes(git_root, [f"changelog/v{version}/release-date.txt"]):
print(" Nothing staged — release-date.txt was already up to date.")
else:
msg_a = f"Set release date {release_date_str} for v{version}"
print(f" Committing: {msg_a!r}")
git(["commit", "-m", msg_a], cwd=git_root, dry_run=dry_run)
# Step 3: commit B — regenerate, then stage CHANGELOG.md + version-summary.md
print(f"\n[3] Running logchangeGenerate (with release date {release_date_str})")
run_logchange_generate(args.gradle_cmd, git_root, dry_run=dry_run)
print(f"\n[3b] Staging CHANGELOG.md and version-summary.md to {release_branch} (commit B)")
git(["add", "CHANGELOG.md", version_summary], cwd=git_root, dry_run=dry_run)
if not dry_run and not has_staged_changes(git_root, ["CHANGELOG.md", version_summary]):
print(" Nothing staged — CHANGELOG.md already up to date.")
else:
msg_b = f"Regenerate CHANGELOG.md for v{version}"
print(f" Committing: {msg_b!r}")
git(["commit", "-m", msg_b], cwd=git_root, dry_run=dry_run)
discard_generated_changelog_files(git_root, dry_run=dry_run)
# Steps 4+5: for each target branch, find commits on release_branch not yet on
# that target, cherry-pick them, then regenerate CHANGELOG.md fresh.
# Only commits touching the version folder are considered: the
# release branch may have diverged from the target long ago (e.g.
# a 9.x release vs main), so other changelog/ commits are feature
# commits that don't belong on the target.
# CHANGELOG.md is never cherry-picked — it is always regenerated so
# each branch gets a correct full-history version (avoids cross-major
# conflicts). version-summary.md is also excluded from cherry-picks
# for the same reason; it is regenerated alongside CHANGELOG.md.
# --cherry-pick is deliberately not used: git would compute patch-ids
# for the whole symmetric difference (thousands of commits across
# majors) before path limiting, taking minutes per target. A commit
# already applied to the target instead cherry-picks to an empty
# change, which recover_cherry_pick skips, so re-running after an
# initial no-push run remains idempotent.
targets = [stable_branch, "main"]
if latest_lts_stable_branch:
targets.append(latest_lts_stable_branch)
version_pathspec = f"changelog/v{version}/"
for target in targets:
print(f"\n[4] Updating {target} and finding {version_pathspec} commits on {release_branch} not yet on it")
git(["checkout", target], cwd=git_root, dry_run=dry_run)
git(["pull", "--ff-only", args.git_remote, target], cwd=git_root, dry_run=dry_run)
if dry_run:
print(f" (dry-run) would run: git log --right-only {target}...{release_branch} -- {version_pathspec} :(exclude)changelog/*/version-summary.md")
commits = []
else:
result = subprocess.run(
["git", "log", "--oneline", "--reverse", "--right-only",
f"{target}...{release_branch}",
"--", version_pathspec, ":(exclude)changelog/*/version-summary.md"],
cwd=git_root, capture_output=True, text=True, check=True,
)
commits = [line.split()[0] for line in result.stdout.strip().splitlines() if line]
if not commits:
print(f" No changelog commits to forward-port — {target} is already up to date.")
else:
print(f" Found {len(commits)} commit(s) to cherry-pick: {', '.join(commits)}")
print(f"\n[5] Cherry-picking {len(commits)} commit(s) to {target}")
for sha in commits:
# Commits that touch changelog/unreleased/ move entries into the
# version folder. -X no-renames makes those plain adds + deletes;
# -X ours settles content conflicts in favour of the target. A
# deletion of an entry that differs on the target still conflicts
# (modify/delete), which recover_cherry_pick resolves.
if commit_touches_unreleased(sha, git_root):
cp_args = ["cherry-pick", "-X", "ours", "-X", "no-renames", sha]
else:
cp_args = ["cherry-pick", sha]
try:
git(cp_args, cwd=git_root, dry_run=dry_run)
except subprocess.CalledProcessError:
if recover_cherry_pick(git_root, version, dry_run=dry_run):
continue
print(f"\nError: cherry-pick of {sha} failed on {target}.",
file=sys.stderr)
print(" Resolve the conflict, then run: git cherry-pick --continue",
file=sys.stderr)
print(" Or abort with: git cherry-pick --abort",
file=sys.stderr)
sys.exit(1)
# Ensure unreleased/ folder exists for contributors after cherry-picks
ensure_unreleased_gitkeep(git_root, dry_run=dry_run)
git(["add", "changelog/unreleased/.gitkeep"], cwd=git_root, dry_run=dry_run)
if not dry_run and has_staged_changes(git_root, ["changelog/unreleased/.gitkeep"]):
git(["commit", "-m", f"Ensure changelog/unreleased/ folder exists on {target}"],
cwd=git_root, dry_run=dry_run)
# Remove any v{version} YAML files that still linger in unreleased/ on this
# target branch, e.g. entries the release branch never had in unreleased/.
version_dir = git_root / f"changelog/v{version}"
stale = (
[
git_root / "changelog/unreleased" / f.name
for f in version_dir.glob("*.yml")
if (git_root / "changelog/unreleased" / f.name).exists()
]
if not dry_run
else []
)
if stale:
rel = [str(p.relative_to(git_root)) for p in stale]
print(f"\n Removing {len(stale)} stale unreleased file(s) from {target}: "
f"{', '.join(p.name for p in stale)}")
git(["rm", "--force"] + rel, cwd=git_root, dry_run=dry_run)
if not dry_run and has_staged_changes(git_root, ["changelog/unreleased/"]):
git(["commit", "-m", f"Remove v{version} entries from unreleased/ on {target}"],
cwd=git_root, dry_run=dry_run)
# Regenerate CHANGELOG.md and version-summary.md fresh on this branch.
print(f"\n Regenerating CHANGELOG.md on {target}")
run_logchange_generate(args.gradle_cmd, git_root, dry_run=dry_run)
git(["add", "CHANGELOG.md", version_summary], cwd=git_root, dry_run=dry_run)
if not dry_run and has_staged_changes(git_root, ["CHANGELOG.md", version_summary]):
git(["commit", "-m", f"Regenerate CHANGELOG.md with v{version} entries on {target}"],
cwd=git_root, dry_run=dry_run)
discard_generated_changelog_files(git_root, dry_run=dry_run)
# Step 6: push (optional)
if do_push:
remote = args.git_remote
branches_to_push = [release_branch, stable_branch, "main"]
if latest_lts_stable_branch:
branches_to_push.append(latest_lts_stable_branch)
print(f"\n[6] Pushing {', '.join(branches_to_push)} to {remote}")
for branch in branches_to_push:
git_push(branch, remote, cwd=git_root, dry_run=dry_run)
else:
print(f"\nCherry-picks done. Review, then re-run with --push to push all branches.")
if not dry_run:
print(f" git log --oneline {stable_branch} -- changelog/")
if dry_run:
print("\nDry-run complete — no changes made.")
elif do_push:
print("\nDone.")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def build_parser():
fmt = lambda prog: argparse.RawDescriptionHelpFormatter(prog, width=120)
parser = argparse.ArgumentParser(
description=__doc__,
epilog="For options on each subcommand run: logchange.py <subcommand> --help",
formatter_class=fmt,
)
sub = parser.add_subparsers(dest="subcommand", required=True)
common = argparse.ArgumentParser(add_help=False)
common.add_argument("--version", required=True,
help="Release version, e.g. 10.1.0")
common.add_argument("--release-branch", required=True,
help="Release branch, e.g. branch_10_1")
common.add_argument("--gradle-cmd", default="./gradlew",
help="Gradle wrapper command (default: ./gradlew)")
common.add_argument("--git-remote", default="origin",
help="Git remote name to push to (default: origin)")
common.add_argument("--dry-run", action="store_true",
help="Print actions without executing or modifying anything")
# prepare
p = sub.add_parser("prepare", parents=[common],
help="Prepare changelog for an RC (RC1 or RC2+)",
description="Prepare the changelog for a release candidate.\n"
"RC1: runs 'gradlew logchangeRelease' to move all unreleased entries to the version folder.\n"
"RC2+: copies only new unreleased entries into the existing version folder.\n"
"In both cases CHANGELOG.md is regenerated. By default changes are left uncommitted for review.",
formatter_class=fmt)
p.add_argument("--commit", action="store_true",
help="Stage and commit the result (default: leave uncommitted for review)")
p.add_argument("--rc-number", type=int, default=1,
help="RC number (default: 1). RC2+ appends ' RC<n>' to commit messages.")
p.add_argument("--skip-validation", action="store_true",
help="Skip pre-flight YAML validation of changelog/unreleased/ files")
p.set_defaults(func=cmd_prepare)
# forward-port
fp = sub.add_parser("forward-port", parents=[common],
help="Set release date and forward-port changelog post-vote",
description="Run once after a successful vote.\n"
"Writes the release date to the version folder and regenerates CHANGELOG.md.\n"
"Then cherry-picks the commits touching changelog/v<version>/ from the release branch to the stable branch and main.\n"
"Requires a clean working tree; each local branch is fast-forwarded from the remote before committing.\n"
"By default nothing is pushed; review first and pass --push when satisfied.",
formatter_class=fmt)
fp.add_argument("--stable-branch", required=True,
help="Stable branch, e.g. branch_10x")
fp.add_argument("--latest-lts-stable-branch",
help="Also forward-port to this LTS stable branch, e.g. branch_9x (LTS releases only)")
fp.add_argument("--release-date",
help="Release date YYYY-MM-DD (default: today)")
fp.add_argument("--push", action="store_true",
help="Push all branches after cherry-picking (default: leave for review)")
fp.set_defaults(func=cmd_forward_port)
return parser
def main():
parser = build_parser()
args = parser.parse_args()
if hasattr(args, 'release_date') and args.release_date:
try:
date.fromisoformat(args.release_date)
except ValueError:
print(f"Error: --release-date must be YYYY-MM-DD, got: {args.release_date!r}",
file=sys.stderr)
sys.exit(1)
try:
git_root = find_git_root()
except subprocess.CalledProcessError:
print("Error: must be run from within the Solr git repository.", file=sys.stderr)
sys.exit(1)
try:
args.func(args, git_root)
except subprocess.CalledProcessError as e:
print(f"\nError: command failed with exit code {e.returncode}", file=sys.stderr)
if e.stderr:
print(e.stderr, file=sys.stderr)
sys.exit(e.returncode)
if __name__ == "__main__":
main()