diff options
Diffstat (limited to 'git_merge_report.py')
| -rw-r--r-- | git_merge_report.py | 217 |
1 files changed, 217 insertions, 0 deletions
diff --git a/git_merge_report.py b/git_merge_report.py new file mode 100644 index 0000000..c351ece --- /dev/null +++ b/git_merge_report.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +""" +git_merge_report.py + +Merges a remote branch into a local branch, and builds a summary report of +the merge (divergence point, commit count, line changes, file changes). +The rendered summary is used as the merge commit message. + +Usage: + python git_merge_report.py <local_branch> <remote_branch> (-m "message" | -f path/to/message.txt) + +Examples: + python git_merge_report.py main feature/login -m "Add login flow" + python git_merge_report.py main feature/login -f commit_msg.txt + python git_merge_report.py main feature/login -m "Add login flow" --dry-run +""" + +import argparse +import re +import subprocess +import sys +from datetime import datetime, timezone + +TEMPLATE = """Merge {remote} into {local}: +Merge commit message: +{msg} + +-----------------------------BEGIN SUMMARY OF MERGE---------------------------- +Merging {n} commits into {local}. +{remote} diverged from {local} at commit {commit_hash} {days} days ago +Commit makes {add_chg} additions, {rem_chg} deletions +Commit adds {add_file} files, removes {rem_file} files +------------------------------END SUMMARY OF MERGE----------------------------- +""" + + +class GitError(RuntimeError): + pass + + +def run_git(args, repo="."): + """Run a git command and return stripped stdout. Raises GitError on failure.""" + try: + result = subprocess.run( + ["git", "-C", repo] + args, + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + except subprocess.CalledProcessError as e: + raise GitError( + f"git {' '.join(args)} failed:\n{e.stderr.strip()}" + ) from e + except FileNotFoundError as e: + raise GitError("git executable not found on PATH") from e + + +def verify_branch_exists(branch, repo="."): + try: + run_git(["rev-parse", "--verify", "--quiet", branch], repo) + except GitError: + raise GitError(f"Branch '{branch}' does not exist in this repository.") + + +def get_merge_base(local, remote, repo="."): + """Commit where remote diverged from local.""" + return run_git(["merge-base", local, remote], repo) + + +def get_commit_date(commit_hash, repo="."): + """ISO8601 commit date of a commit, as a timezone-aware datetime.""" + iso = run_git(["show", "-s", "--format=%cI", commit_hash], repo) + return datetime.fromisoformat(iso) + + +def days_since(commit_hash, repo="."): + commit_dt = get_commit_date(commit_hash, repo) + now = datetime.now(timezone.utc) + delta = now - commit_dt.astimezone(timezone.utc) + return delta.days + + +def get_commit_count(local, remote, repo="."): + """Number of commits on remote not yet on local (i.e. being merged in).""" + out = run_git(["rev-list", "--count", f"{local}..{remote}"], repo) + return int(out) + + +def get_diff_shortstat(local, remote, repo="."): + """Lines added/removed between merge-base(local,remote) and remote.""" + out = run_git(["diff", "--shortstat", f"{local}...{remote}"], repo) + added = removed = 0 + if out: + add_match = re.search(r"(\d+) insertion", out) + rem_match = re.search(r"(\d+) deletion", out) + if add_match: + added = int(add_match.group(1)) + if rem_match: + removed = int(rem_match.group(1)) + return added, removed + + +def get_file_changes(local, remote, repo="."): + """Count of files added (A) and removed (D) between local and remote.""" + out = run_git(["diff", "--name-status", f"{local}...{remote}"], repo) + added_files = removed_files = 0 + for line in out.splitlines(): + if not line.strip(): + continue + status = line.split("\t", 1)[0] + # status can be 'A', 'D', 'M', or rename/copy scores like 'R100' + if status.startswith("A"): + added_files += 1 + elif status.startswith("D"): + removed_files += 1 + return added_files, removed_files + + +def read_message(args): + if args.message is not None: + return args.message + with open(args.message_file, "r", encoding="utf-8") as f: + return f.read().strip() + return "None" + + +def build_summary(local, remote, msg, repo="."): + merge_base_hash = get_merge_base(local, remote, repo) + short_hash = run_git(["rev-parse", "--short", merge_base_hash], repo) + n_days = days_since(merge_base_hash, repo) + n_commits = get_commit_count(local, remote, repo) + add_chg, rem_chg = get_diff_shortstat(local, remote, repo) + add_file, rem_file = get_file_changes(local, remote, repo) + + summary = TEMPLATE.format( + remote=remote, + local=local, + msg=msg, + n=n_commits, + commit_hash=short_hash, + days=n_days, + add_chg=add_chg, + rem_chg=rem_chg, + add_file=add_file, + rem_file=rem_file, + ) + return summary + + +def perform_merge(local, remote, commit_message, repo="."): + run_git(["checkout", local], repo) + run_git(["merge", "--no-ff", remote, "-m", commit_message], repo) + + +def parse_args(argv=None): + parser = argparse.ArgumentParser( + description="Merge a remote branch into a local branch and report a summary." + ) + parser.add_argument("local_branch", help="Branch to merge INTO") + parser.add_argument("remote_branch", help="Branch to merge (source)") + + msg_group = parser.add_mutually_exclusive_group(required=True) + msg_group.add_argument( + "-m", "--message", help="Merge commit message, given directly as text" + ) + msg_group.add_argument( + "-f", + "--message-file", + help="Path to a file containing the merge commit message", + ) + + parser.add_argument( + "--repo", + default=".", + help="Path to the git repository (default: current directory)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Only print the summary report; do not perform the actual merge", + ) + + return parser.parse_args(argv) + + +def main(argv=None): + args = parse_args(argv) + repo = args.repo + + try: + verify_branch_exists(args.local_branch, repo) + verify_branch_exists(args.remote_branch, repo) + + msg = read_message(args) + summary = build_summary(args.local_branch, args.remote_branch, msg, repo) + + print(summary) + + if args.dry_run: + print("[dry-run] No merge performed.") + return 0 + + perform_merge(args.local_branch, args.remote_branch, summary, repo) + print(f"Merge complete: '{args.remote_branch}' merged into '{args.local_branch}'.") + return 0 + + except GitError as e: + print(f"Error: {e}", file=sys.stderr) + return 1 + except FileNotFoundError: + print(f"Error: message file '{args.message_file}' not found.", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) |
