summaryrefslogtreecommitdiff
path: root/git-gerrit-diff
blob: c993ea86af1e51c87372160e2d58b0b863190389 (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
218
219
220
221
222
223
224
#!/usr/bin/env python3
"""
Finds the differences between two given trees based on the Change-Id.

First all commits not merged into upstream are shown, then all changes that
are not merged into HEAD are displayed. Before each commit, "Not merged in
(branch)" is displayed. Note that contextual differences or patch revisions are
ignored, a change is simply identified by its Change-Id. It is tested with the
Wireshark repository which uses a linear history in a branch without merges.

When standard output is a terminal, a less piper will be opened, searching for
lines beginning with "commit". Then you can press "n" and "p" to jump between
commits.

Examples:

    # Assume that this script is located at ~/scripts/, add an alias and mark it
    # such that it completes like "git log" (spaces around log is significant).
    git config --add alias.gerrit-diff '!: log ; ~/scripts/git-gerrit-diff'

    # Check whether the current branch is merged into upstream master
    git gerrit-diff

    # Check whether the current branch is merged into upstream master-2.0
    git gerrit-diff origin/master-2.0

    # Check whether the branch bug/12345 is merged into upstream master
    git gerrit-diff origin/master-2.0 bug/12345

    # Add options to git-log
    git gerrit-diff origin/master-2.0 --stat

    # Do not pipe the output in a "less" pipe. Normally "less" is executed when
    # the output is a tty and "--color" is added to the git-log options.
    git gerrit-diff --no-pager origin/master-2.0
"""

import re, os, subprocess, sys
from collections import OrderedDict

def print_usage():
    print("""
Usage: git gerrit-diff <options> [<upstream> [<head>]] [<git log options>]

    --no-pager          disable the less pager
    --no-color          omit --color option from git-log
    -h, --help          show this help
    -v, --verbose       be verbose

    upstream            Upstream branch to search for equivalent commits.
                        Defaults to the upstream branch of HEAD.
    head                Working branch; defaults to HEAD
""")
    sys.exit(0)

def parse_arguments():
    no_pager = False
    no_color = False
    upstream = None
    head = "HEAD"
    git_options = []

    args = sys.argv[1:]
    # Parse options of this script
    count = 0
    for i, arg in enumerate(args):
        if arg in ("-h", "--help"):
            print_usage()
        elif arg == "--no-pager":
            no_pager = True
        elif arg == "--no-color":
            no_color = True
        else:
            break
        count += 1
    # Ignore processed options
    del args[0:count]

    # Parse upstream and head
    count = 0
    for i, arg in enumerate(args):
        # Stop as soon as other options are visible.
        if arg.startswith("-"):
            break
        if i == 0:
            upstream = arg
        elif i == 1:
            head = arg
        else:
            break
        count += 1
    # Ignore upstream and head
    del args[:count]

    # Always enable colors when the output is a TTY unless overridden.
    if not no_color and os.isatty(sys.stdout.fileno()):
        git_options.append("--color")
    # remaining arguments are for git
    git_options += args

    return no_pager, upstream, head, git_options

def split_args(args):
    options, files = args, []
    for i, arg in enumerate(args):
        if arg == "--":
            if i == 0:
                options, files = [], args[1:]
            else:
                options, files = args[:i], args[i+1:]
            break
        elif not arg.startswith("-"):
            options, files = args[:i], args[i:]
            break
    if files and files[0] == "--":
        del a[:1]
    return options, files

def strip_color(text):
    return re.sub("\033[^m]*m", "", text)


def get_upstream_branch():
    return subprocess.check_output(["git", "rev-parse", "--abbrev-ref",
        "@{upstream}"], universal_newlines=True).strip()

def get_merge_base(commit1, commit2):
    return subprocess.check_output(["git", "merge-base", commit1, commit2],
            universal_newlines=True).strip()

def get_commits(commit_range, git_options, git_paths):
    cmd = ["git", "log"] + git_options + [commit_range, "--"] + git_paths
    git_proc = subprocess.Popen(cmd, universal_newlines=True,
            stdin=subprocess.DEVNULL, stdout=subprocess.PIPE)
    commit_hash = ""
    block = ""
    for line in git_proc.stdout:
        if strip_color(line).startswith("commit "):
            if block:
                yield commit_hash, block
            block = ""
            commit_hash = line.split()[1]
        block += line
    if block:
        if not commit_hash:
            raise KeyError("Cannot find commit ID")
        yield commit_hash, block + "\n"

def get_change_id(commit, identifier):
    text = strip_color(commit)
    m = re.search("^    Change-Id: (I[0-9a-f]{40})$", text, re.M | re.I)
    if not m:
        raise KeyError("Change-Id not found for %s" % identifier)
    return m.group(1)

def get_changes(commit_base, commit_head, *args):
    commit_range = "%s...%s" % (commit_base, commit_head)
    changes = OrderedDict()
    for commit_hash, commit in get_commits(commit_range, *args):
        try:
            change_id = get_change_id(commit, commit_hash)
        except KeyError as e:
            # Note: this can happen when entering the SVN history (Jan 2014).
            # Ignore older commits for now.
            print(e, file=sys.stderr)
            print("Skipping following commits", file=sys.stderr)
            break
        changes[change_id] = commit
    return changes

def main():
    no_pager, upstream, head, git_log_args = parse_arguments()

    # (options, file_paths) for git-log
    git_args = split_args(git_log_args)

    if upstream is None:
        upstream = get_upstream_branch()

    # Find the point before the branches diverged.
    commit_base = get_merge_base(upstream, head)

    # Find all commits for each branch
    upstream_changes = get_changes(commit_base, upstream, *git_args)
    head_changes = get_changes(commit_base, head, *git_args)

    if not no_pager and os.isatty(sys.stdout.fileno()):
        less_pipe = subprocess.Popen(["less", "-Rj2", "+/^commit"],
                universal_newlines=True, stdin=subprocess.PIPE)
        output = less_pipe.stdin
    else:
        less_pipe = None
        output = sys.stdout

    def print_change(change, upstream_missing):
        where = upstream if upstream_missing else head
        output.write("Not merged in %s\n" % where)
        output.write(change)

    # Print changes with are not in upstream...
    for change_id, change in head_changes.items():
        if change_id not in upstream_changes:
            print_change(change, True)

    # ...and upstream changes not in HEAD
    if False:
        # XXX maybe add an option to show these changes too?
        for change_id, change in upstream_changes.items():
            if change_id not in head_changes:
                print_change(change, False)

    if less_pipe is not None:
        less_pipe.stdin.close()
        less_pipe.wait()

if __name__ == "__main__":
    try:
        main()
    except BrokenPipeError:
        # Ignore broken pipe errors, it is not that interesting.
        pass
    except subprocess.CalledProcessError as e:
        # Git errors
        sys.exit(e.returncode)