summaryrefslogtreecommitdiff
path: root/git_merge_report.py
blob: c351ece93d1181784cd5ebe3329ee0455a9d302e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
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())