6c088bf30c
Add the release-version skill with changelog guidance, agent metadata, and a helper script for collecting release context. Also tighten the git-commit skill so changed submodule pointers must be verified on submodule remotes before the parent repository is committed.
222 lines
6.9 KiB
Python
Executable File
222 lines
6.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Collect release context between a target tag and its comparison baseline."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
SEMVER_TAG_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$")
|
|
|
|
|
|
def run_git(
|
|
repo: Path,
|
|
args: list[str],
|
|
allow_failure: bool = False,
|
|
timeout_seconds: int = 15,
|
|
) -> str:
|
|
try:
|
|
result = subprocess.run(
|
|
["git", *args],
|
|
cwd=repo,
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
timeout=timeout_seconds,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
if allow_failure:
|
|
return ""
|
|
raise RuntimeError(f"git command timed out: git {' '.join(args)}")
|
|
if result.returncode != 0 and not allow_failure:
|
|
raise RuntimeError(result.stderr.strip() or "git command failed")
|
|
return result.stdout.strip()
|
|
|
|
|
|
def parse_semver(tag: str) -> tuple[int, int, int] | None:
|
|
match = SEMVER_TAG_RE.match(tag)
|
|
if not match:
|
|
return None
|
|
return tuple(int(part) for part in match.groups())
|
|
|
|
|
|
def list_reachable_tags(repo: Path) -> list[str]:
|
|
output = run_git(repo, ["tag", "--merged", "HEAD"])
|
|
return [line.strip() for line in output.splitlines() if line.strip()]
|
|
|
|
|
|
def choose_previous_tag(repo: Path, tags: list[str], target_tag: str | None) -> tuple[str | None, str | None]:
|
|
semver_tags: list[tuple[tuple[int, int, int], str]] = []
|
|
other_tags: list[str] = []
|
|
target_semver = parse_semver(target_tag) if target_tag else None
|
|
|
|
for tag in tags:
|
|
if target_tag and tag == target_tag:
|
|
continue
|
|
parsed = parse_semver(tag)
|
|
if parsed is None:
|
|
other_tags.append(tag)
|
|
continue
|
|
if target_semver is not None and parsed >= target_semver:
|
|
continue
|
|
semver_tags.append((parsed, tag))
|
|
|
|
if semver_tags:
|
|
semver_tags.sort(key=lambda item: item[0], reverse=True)
|
|
return semver_tags[0][1], "semver"
|
|
|
|
if not other_tags:
|
|
return None, None
|
|
|
|
candidates: list[tuple[int, str]] = []
|
|
for tag in other_tags:
|
|
ts = run_git(repo, ["log", "-1", "--format=%ct", tag], allow_failure=True)
|
|
try:
|
|
candidates.append((int(ts), tag))
|
|
except ValueError:
|
|
continue
|
|
if not candidates:
|
|
return other_tags[-1], "fallback"
|
|
candidates.sort(reverse=True)
|
|
return candidates[0][1], "fallback"
|
|
|
|
|
|
def ensure_tag_absent(repo: Path, tag: str) -> None:
|
|
local = run_git(repo, ["tag", "--list", tag])
|
|
if local:
|
|
raise ValueError(f"target tag already exists locally: {tag}")
|
|
|
|
remotes_output = run_git(repo, ["remote"])
|
|
for remote in [line.strip() for line in remotes_output.splitlines() if line.strip()]:
|
|
remote_hit = run_git(
|
|
repo,
|
|
["ls-remote", "--tags", remote, tag],
|
|
allow_failure=True,
|
|
timeout_seconds=8,
|
|
)
|
|
if remote_hit:
|
|
raise ValueError(f"target tag already exists on remote {remote}: {tag}")
|
|
|
|
|
|
def ensure_tag_exists(repo: Path, tag: str) -> None:
|
|
hit = run_git(repo, ["rev-parse", "--verify", "--quiet", tag], allow_failure=True)
|
|
if not hit:
|
|
raise ValueError(f"previous tag does not exist locally: {tag}")
|
|
|
|
|
|
def get_commit_list(repo: Path, rev_range: str) -> list[dict[str, str]]:
|
|
output = run_git(repo, ["log", "--reverse", "--date=short", "--pretty=format:%H%x09%ad%x09%s", rev_range])
|
|
commits: list[dict[str, str]] = []
|
|
for line in output.splitlines():
|
|
commit_hash, date_str, subject = line.split("\t", 2)
|
|
commits.append(
|
|
{
|
|
"hash": commit_hash,
|
|
"short_hash": commit_hash[:7],
|
|
"date": date_str,
|
|
"subject": subject,
|
|
}
|
|
)
|
|
return commits
|
|
|
|
|
|
def get_changed_files(repo: Path, rev_range: str) -> list[str]:
|
|
output = run_git(repo, ["diff", "--name-only", rev_range])
|
|
return [line.strip() for line in output.splitlines() if line.strip()]
|
|
|
|
|
|
def get_numstat(repo: Path, rev_range: str) -> dict[str, int]:
|
|
output = run_git(repo, ["diff", "--numstat", rev_range])
|
|
changed_files = 0
|
|
insertions = 0
|
|
deletions = 0
|
|
for line in output.splitlines():
|
|
parts = line.split("\t")
|
|
if len(parts) < 3:
|
|
continue
|
|
added, removed = parts[0], parts[1]
|
|
changed_files += 1
|
|
if added.isdigit():
|
|
insertions += int(added)
|
|
if removed.isdigit():
|
|
deletions += int(removed)
|
|
return {
|
|
"changed_files": changed_files,
|
|
"insertions": insertions,
|
|
"deletions": deletions,
|
|
}
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--repo", default=".", help="Repository root. Defaults to current directory.")
|
|
parser.add_argument("--tag", help="Target release tag to validate, for example v1.2.3.")
|
|
parser.add_argument("--previous-tag", help="Explicit comparison baseline.")
|
|
return parser
|
|
|
|
|
|
def main() -> int:
|
|
args = build_parser().parse_args()
|
|
repo_path = Path(args.repo).expanduser().resolve()
|
|
|
|
if not (repo_path / ".git").exists():
|
|
print(json.dumps({"error": f"not a git repository: {repo_path}"}))
|
|
return 1
|
|
|
|
if args.tag and parse_semver(args.tag) is None:
|
|
print(json.dumps({"error": f"invalid tag format: {args.tag}", "expected": "vx.y.z"}))
|
|
return 1
|
|
|
|
try:
|
|
if args.tag:
|
|
ensure_tag_absent(repo_path, args.tag)
|
|
if args.previous_tag:
|
|
ensure_tag_exists(repo_path, args.previous_tag)
|
|
except ValueError as exc:
|
|
print(json.dumps({"error": str(exc)}))
|
|
return 1
|
|
|
|
tags = list_reachable_tags(repo_path)
|
|
previous_tag = args.previous_tag
|
|
previous_tag_source = "explicit" if previous_tag else None
|
|
|
|
if previous_tag is None:
|
|
previous_tag, previous_tag_source = choose_previous_tag(repo_path, tags, args.tag)
|
|
|
|
rev_range = "HEAD"
|
|
if previous_tag:
|
|
rev_range = f"{previous_tag}..HEAD"
|
|
|
|
try:
|
|
commits = get_commit_list(repo_path, rev_range)
|
|
changed_files = get_changed_files(repo_path, rev_range)
|
|
stats = get_numstat(repo_path, rev_range)
|
|
head_commit = run_git(repo_path, ["rev-parse", "HEAD"])
|
|
except RuntimeError as exc:
|
|
print(json.dumps({"error": str(exc)}))
|
|
return 1
|
|
|
|
payload: dict[str, object] = {
|
|
"repo": str(repo_path),
|
|
"target_tag": args.tag,
|
|
"previous_tag": previous_tag,
|
|
"previous_tag_source": previous_tag_source,
|
|
"rev_range": rev_range,
|
|
"head_commit": head_commit,
|
|
"commit_count": len(commits),
|
|
"commits": commits,
|
|
"changed_files": changed_files,
|
|
}
|
|
payload.update(stats)
|
|
|
|
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|