diff --git a/local/bin/git-when-merged b/local/bin/git-when-merged new file mode 100755 index 0000000..6b12e08 --- /dev/null +++ b/local/bin/git-when-merged @@ -0,0 +1,429 @@ +#!/usr/bin/env python +# -*- mode: python; coding: utf-8 -*- + +# Copyright (c) 2013 Michael Haggerty +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see + +# Run "git when-merged --help for the documentation. + +"""Find when a commit was merged into one or more branches. + +Find the merge commit that brought COMMIT into the specified +BRANCH(es). Specificially, look for the oldest commit on the +first-parent history of BRANCH that contains the COMMIT as an +ancestor. + +""" + +USAGE = r"""git when-merged [OPTIONS] COMMIT [BRANCH...] +""" + +EPILOG = r""" + COMMIT + a commit whose destiny you would like to determine (this + argument is required) + + BRANCH... + the destination branches into which might have been + merged. (Actually, BRANCH can be an arbitrary commit, specified + in any way that is understood by git-rev-parse(1).) If neither + nor -p/--pattern nor -s/--default is specified, then + HEAD is used + +Examples: + git when-merged 0a1b # Find merge into current branch + git when-merged 0a1b feature-1 feature-2 # Find merge into given branches + git when-merged 0a1b -p feature-[0-9]+ # Specify branches by regex + git when-merged 0a1b -n releases # Use whenmerged.releases.pattern + git when-merged 0a1b -s # Use whenmerged.default.pattern + + git when-merged 0a1b -d feature-1 # Show diff for each merge commit + git when-merged 0a1b -v feature-1 # Display merge commit in gitk + +Configuration: + whenmerged..pattern + Regular expressions that match reference names for the pattern + called . A regexp is sought in the full reference name, + in the form "refs/heads/master". This option can be + multivalued, in which case references matching any of the + patterns are considered. Typically you will use pattern(s) that + match master and/or significant release branches, or perhaps + their remote-tracking equivalents. For example, + + git config whenmerged.default.pattern \ + '^refs/heads/master$' + + or + + git config whenmerged.releases.pattern \ + '^refs/remotes/origin/release\-\d+\.\d+$' + + whenmerged.abbrev + If this value is set to a positive integer, then Git SHA1s are + abbreviated to this number of characters (or longer if needed to + avoid ambiguity). This value can be overridden using --abbrev=N + or --no-abbrev. + +Based on: + http://stackoverflow.com/questions/8475448/find-merge-commit-which-include-a-specific-commit +""" + +import sys +import re +import subprocess +import optparse + + +if not (0x02060000 <= sys.hexversion): + sys.exit('Python version 2.6 or later is required') + + +# Backwards compatibility: +try: + from subprocess import CalledProcessError +except ImportError: + # Use definition from Python 2.7 subprocess module: + class CalledProcessError(Exception): + def __init__(self, returncode, cmd, output=None): + self.returncode = returncode + self.cmd = cmd + self.output = output + def __str__(self): + return "Command '%s' returned non-zero exit status %d" % (self.cmd, self.returncode) + +try: + from subprocess import check_output +except ImportError: + # Use definition from Python 2.7 subprocess module: + def check_output(*popenargs, **kwargs): + if 'stdout' in kwargs: + raise ValueError('stdout argument not allowed, it will be overridden.') + process = subprocess.Popen(stdout=subprocess.PIPE, *popenargs, **kwargs) + output, unused_err = process.communicate() + retcode = process.poll() + if retcode: + cmd = kwargs.get("args") + if cmd is None: + cmd = popenargs[0] + try: + raise CalledProcessError(retcode, cmd, output=output) + except TypeError: + # Python 2.6's CalledProcessError has no 'output' kw + raise CalledProcessError(retcode, cmd) + return output + + +class Failure(Exception): + pass + + +def _decode_output(value): + """Decodes Git output into a unicode string. + + On Python 2 this is a no-op; on Python 3 we decode the string as + suggested by [1] since we know that Git treats paths as just a sequence + of bytes and all of the output we ask Git for is expected to be a file + system path. + + [1] http://docs.python.org/3/c-api/unicode.html#file-system-encoding + + """ + if sys.hexversion < 0x3000000: + return value + return value.decode(sys.getfilesystemencoding(), 'surrogateescape') + + +def check_git_output(*popenargs, **kwargs): + return _decode_output(check_output(*popenargs, **kwargs)) + + +def read_refpatterns(name): + key = 'whenmerged.%s.pattern' % (name,) + try: + out = check_git_output(['git', 'config', '--get-all', '--null', key]) + except CalledProcessError: + raise Failure('There is no configuration setting for %r!' % (key,)) + retval = [] + for value in out.split('\0'): + if value: + try: + retval.append(re.compile(value)) + except re.error as e: + sys.stderr.write( + 'Error compiling branch pattern %r; ignoring: %s\n' + % (value, e.message,) + ) + return retval + + +def iter_commit_refs(): + """Iterate over the names of references that refer to commits. + + (This includes references that refer to annotated tags that refer + to commits.)""" + + process = subprocess.Popen( + [ + 'git', 'for-each-ref', + '--format=%(refname) %(objecttype) %(*objecttype)', + ], + stdout=subprocess.PIPE, + ) + for line in process.stdout: + words = _decode_output(line).strip().split() + refname = words.pop(0) + if words == ['commit'] or words == ['tag', 'commit']: + yield refname + + retcode = process.wait() + if retcode: + raise Failure('git for-each-ref failed') + + +def matches_any(refname, refpatterns): + return any( + refpattern.search(refname) + for refpattern in refpatterns + ) + + +def rev_parse(arg, abbrev=None): + if abbrev: + cmd = ['git', 'rev-parse', '--verify', '-q', '--short=%d' % (abbrev,), arg] + else: + cmd = ['git', 'rev-parse', '--verify', '-q', arg] + + try: + return check_git_output(cmd).strip() + except CalledProcessError: + raise Failure('%r is not a valid commit!' % (arg,)) + + +def rev_list(*args): + process = subprocess.Popen( + ['git', 'rev-list'] + list(args) + ['--'], + stdout=subprocess.PIPE, + ) + for line in process.stdout: + yield _decode_output(line).strip() + + retcode = process.wait() + if retcode: + raise Failure('git rev-list %s failed' % (' '.join(args),)) + + +FORMAT = '%(refname)-38s %(msg)s\n' + +def find_merge(commit, branch, abbrev): + """Return the SHA1 of the commit that merged commit into branch. + + It is assumed that content is always merged in via the second or + subsequent parents of a merge commit.""" + + try: + branch_sha1 = rev_parse(branch) + except Failure as e: + sys.stdout.write(FORMAT % dict(refname=branch, msg='Is not a valid commit!')) + return None + + branch_commits = set( + rev_list('--first-parent', branch_sha1, '--not', '%s^@' % (commit,)) + ) + + if commit in branch_commits: + sys.stdout.write(FORMAT % dict(refname=branch, msg='Commit is directly on this branch.')) + return None + + last = None + for commit in rev_list('--ancestry-path', '%s..%s' % (commit, branch_sha1,)): + if commit in branch_commits: + last = commit + + if not last: + sys.stdout.write(FORMAT % dict(refname=branch, msg='Does not contain commit.')) + else: + if abbrev is not None: + msg = rev_parse(last, abbrev=abbrev) + else: + msg = last + sys.stdout.write(FORMAT % dict(refname=branch, msg=msg)) + + return last + + +class Parser(optparse.OptionParser): + """An OptionParser that doesn't reflow usage and epilog.""" + + def get_usage(self): + return self.usage + + def format_epilog(self, formatter): + return self.epilog + + +def get_full_name(branch): + """Return the full name of the specified commit. + + If branch is a symbolic reference, return the name of the + reference that it refers to. If it is an abbreviated reference + name (e.g., "master"), return the full reference name (e.g., + "refs/heads/master"). Otherwise, just verify that it is valid, + but return the original value.""" + + try: + full = check_git_output( + ['git', 'rev-parse', '--verify', '-q', '--symbolic-full-name', branch] + ).strip() + # The above call exits successfully, with no output, if branch + # is not a reference at all. So only use the value if it is + # not empty. + if full: + return full + except CalledProcessError: + pass + + # branch was not a reference, so just verify that it is valid but + # leave it in its original form: + rev_parse(branch) + return branch + + +def main(args): + parser = Parser( + prog='git when-merged', + description=__doc__, + usage=USAGE, + epilog=EPILOG, + ) + + try: + default_abbrev = int( + check_git_output(['git', 'config', '--int', 'whenmerged.abbrev']).strip() + ) + except CalledProcessError: + default_abbrev = None + + parser.add_option( + '--pattern', '-p', metavar='PATTERN', + action='append', dest='patterns', default=[], + help=( + 'Show when COMMIT was merged to the references matching ' + 'the specified regexp. If the regexp has parentheses for ' + 'grouping, then display in the output the part of the ' + 'reference name matching the first group.' + ), + ) + parser.add_option( + '--name', '-n', metavar='NAME', + action='append', dest='names', default=[], + help=( + 'Show when COMMIT was merged to the references matching the ' + 'configured pattern(s) with the given name (see ' + 'whenmerged..pattern below under CONFIGURATION).' + ), + ) + parser.add_option( + '--default', '-s', + action='append_const', dest='names', const='default', + help='Shorthand for "--name=default".', + ) + parser.add_option( + '--abbrev', metavar='N', + action='store', type='int', default=default_abbrev, + help=( + 'Abbreviate commit SHA1s to the specified number of characters ' + '(or more if needed to avoid ambiguity). ' + 'See also whenmerged.abbrev below under CONFIGURATION.' + ), + ) + parser.add_option( + '--no-abbrev', dest='abbrev', action='store_const', const=None, + help='Do not abbreviate commit SHA1s.', + ) + parser.add_option( + '--diff', '-d', action='store_true', default=False, + help='Show the diff for the merge commit.', + ) + parser.add_option( + '--visualize', '-v', action='store_true', default=False, + help='Visualize the merge commit using gitk.', + ) + + (options, args) = parser.parse_args(args) + + if not args: + parser.error('You must specify a COMMIT argument') + + if options.abbrev is not None and options.abbrev <= 0: + options.abbrev = None + + commit = args.pop(0) + # Convert commit into a SHA1: + try: + commit = rev_parse(commit) + except Failure as e: + sys.exit(e.message) + + refpatterns = [] + + for value in options.patterns: + try: + refpatterns.append(re.compile(value)) + except re.error as e: + sys.stderr.write( + 'Error compiling pattern %r; ignoring: %s\n' + % (value, e.message,) + ) + + for value in options.names: + try: + refpatterns.extend(read_refpatterns(value)) + except Failure as e: + sys.exit(e.message) + + branches = set() + + if refpatterns: + branches.update( + refname + for refname in iter_commit_refs() + if matches_any(refname, refpatterns) + ) + + for branch in args: + try: + branches.add(get_full_name(branch)) + except Failure as e: + sys.exit(e.message) + + if not branches: + branches.add(get_full_name('HEAD')) + + for branch in sorted(branches): + try: + merge = find_merge(commit, branch, options.abbrev) + except Failure as e: + sys.stderr.write('%s\n' % (e.message,)) + continue + + if merge: + if options.diff: + subprocess.check_call(['git', 'show', merge]) + + if options.visualize: + subprocess.check_call(['gitk', '--all', '--select-commit=%s' % (merge,)]) + + +main(sys.argv[1:])