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