186 lines
6 KiB
Python
Executable file
186 lines
6 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
|
|
try:
|
|
from datetime import UTC, datetime
|
|
except ImportError:
|
|
from datetime import datetime, timezone
|
|
|
|
UTC = timezone.utc
|
|
|
|
try:
|
|
import git
|
|
except ImportError:
|
|
print("Please install GitPython: pip install GitPython", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def get_sorted_tags(repo):
|
|
"""Returns all tags sorted by their commit date (newest first)."""
|
|
return sorted(repo.tags, key=lambda t: t.commit.committed_datetime, reverse=True)
|
|
|
|
|
|
def get_commits_between(repo, start, end):
|
|
"""Return commits between two refs (no merges)."""
|
|
return list(repo.iter_commits(f"{start}..{end}", no_merges=True))
|
|
|
|
|
|
CO_AUTHORED_BY_RE = re.compile(r"^Co-authored-by:\s.*$", re.IGNORECASE)
|
|
|
|
|
|
def sanitize_commit_message(message):
|
|
"""Remove co-author trailers from commit messages before publishing."""
|
|
lines = [line for line in message.strip().splitlines() if not CO_AUTHORED_BY_RE.match(line)]
|
|
return "\n".join(lines).strip()
|
|
|
|
|
|
def get_preferred_ref(repo, *names):
|
|
"""Return the first matching ref by name."""
|
|
refs_by_name = {ref.name: ref for ref in repo.refs}
|
|
for name in names:
|
|
ref = refs_by_name.get(name)
|
|
if ref is not None:
|
|
return ref
|
|
return None
|
|
|
|
|
|
def add_commits_by_sha(target, commits):
|
|
"""Store commits by SHA so overlapping refs only appear once."""
|
|
for commit in commits:
|
|
target[commit.hexsha] = commit
|
|
|
|
|
|
def build_untagged_entry(commits_by_sha):
|
|
"""Create one entry for commits that are not part of a tag yet."""
|
|
if not commits_by_sha:
|
|
return None
|
|
|
|
commits = sorted(
|
|
commits_by_sha.values(), key=lambda c: c.committed_datetime, reverse=True
|
|
)
|
|
latest_commit = commits[0]
|
|
return {
|
|
"tag": "Untagged",
|
|
"full_sha": latest_commit.hexsha,
|
|
"date": latest_commit.committed_datetime.astimezone(UTC).isoformat(),
|
|
"commits": [
|
|
{
|
|
"sha": c.hexsha[:8],
|
|
"full_sha": c.hexsha,
|
|
"message": sanitize_commit_message(c.message),
|
|
"author": c.author.name,
|
|
"date": c.committed_datetime.astimezone(UTC).isoformat(),
|
|
}
|
|
for c in commits
|
|
],
|
|
}
|
|
|
|
|
|
def generate_changelog(repo_path, changelog_path):
|
|
repo = git.Repo(repo_path)
|
|
tags = get_sorted_tags(repo)
|
|
changelog = []
|
|
|
|
head_commit = repo.head.commit
|
|
head_branch = None
|
|
untagged_commits = {}
|
|
try:
|
|
head_branch = repo.active_branch.name
|
|
except TypeError:
|
|
pass # Detached HEAD
|
|
|
|
# Collect dev commits not yet in main so they share one untagged bucket.
|
|
if head_branch != "dev":
|
|
try:
|
|
dev_branch = get_preferred_ref(repo, "origin/dev", "dev")
|
|
main_branch = get_preferred_ref(
|
|
repo, "origin/main", "origin/master", "main", "master"
|
|
)
|
|
|
|
if dev_branch is not None and main_branch is not None:
|
|
unmerged_commits = list(
|
|
repo.iter_commits(
|
|
f"{main_branch.name}..{dev_branch.name}", no_merges=True
|
|
)
|
|
)
|
|
add_commits_by_sha(untagged_commits, unmerged_commits)
|
|
except Exception as e:
|
|
print(f"[WARN] Could not get unmerged dev commits: {e}", file=sys.stderr)
|
|
|
|
# Regular tag-based changelog
|
|
if not tags:
|
|
start = repo.commit(repo.git.rev_list("--max-parents=0", "HEAD"))
|
|
commits = get_commits_between(repo, start.hexsha, "HEAD")
|
|
changelog.append(
|
|
{
|
|
"tag": "Initial Release",
|
|
"date": datetime.now(UTC).isoformat(),
|
|
"commits": [
|
|
{
|
|
"sha": c.hexsha[:8],
|
|
"full_sha": c.hexsha,
|
|
"message": sanitize_commit_message(c.message),
|
|
"author": c.author.name,
|
|
"date": c.committed_datetime.astimezone(UTC).isoformat(),
|
|
}
|
|
for c in commits
|
|
],
|
|
}
|
|
)
|
|
else:
|
|
for i in range(len(tags) - 1):
|
|
newer, older = tags[i], tags[i + 1]
|
|
commits = get_commits_between(
|
|
repo, older.commit.hexsha, newer.commit.hexsha
|
|
)
|
|
if not commits:
|
|
continue
|
|
changelog.append(
|
|
{
|
|
"tag": newer.name,
|
|
"full_sha": newer.commit.hexsha,
|
|
"date": newer.commit.committed_datetime.astimezone(UTC).isoformat(),
|
|
"commits": [
|
|
{
|
|
"sha": c.hexsha[:8],
|
|
"full_sha": c.hexsha,
|
|
"message": sanitize_commit_message(c.message),
|
|
"author": c.author.name,
|
|
"date": c.committed_datetime.astimezone(UTC).isoformat(),
|
|
}
|
|
for c in commits
|
|
],
|
|
}
|
|
)
|
|
|
|
# Collect commits after the latest tag into the same untagged bucket.
|
|
if head_commit.hexsha != tags[0].commit.hexsha:
|
|
commits = get_commits_between(
|
|
repo, tags[0].commit.hexsha, head_commit.hexsha
|
|
)
|
|
if commits:
|
|
add_commits_by_sha(untagged_commits, commits)
|
|
|
|
untagged_entry = build_untagged_entry(untagged_commits)
|
|
if untagged_entry:
|
|
changelog.insert(0, untagged_entry)
|
|
|
|
with open(changelog_path, "w", encoding="utf-8") as f:
|
|
json.dump(changelog, f, indent=2)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser(
|
|
description="Generate CHANGELOG.json from Git tags."
|
|
)
|
|
parser.add_argument("--repo_path", "-p", default=".", help="Path to git repo")
|
|
parser.add_argument(
|
|
"--changelog_path", "-f", default="CHANGELOG.json", help="Output file path"
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
generate_changelog(args.repo_path, args.changelog_path)
|