#!/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 (-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())