summaryrefslogtreecommitdiff
path: root/git_merge_report.py
diff options
context:
space:
mode:
authorJoshua Liu <joshua.liu@sourceobby.com>2026-09-12 22:27:37 -0400
committerJoshua Liu <joshua.liu@sourceobby.com>2026-09-12 22:27:37 -0400
commitd888cf849959327c516ac0dbcfeb81c515c68e6c (patch)
treec9aa91314fb3eae8bd8352933138aabca5487648 /git_merge_report.py
feat: initial commitmaster
Diffstat (limited to 'git_merge_report.py')
-rw-r--r--git_merge_report.py217
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())