bin/git-when-merged
#!/usr/bin/env python
# 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 <http://www.gnu.org/licenses/>
# Run "git when-merged --help for the documentation.
__doc__ = \
"""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 <commit> 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
<branch> 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.<name>.pattern
Regular expressions that match reference names for the pattern
called <name>. 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
# 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 read_refpatterns(name):
key = 'whenmerged.%s.pattern' % (name,)
try:
out = check_output(['git', 'config', '--get-all', '--null', key]).decode('UTF-8')
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, 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 = line.strip().decode('UTF-8').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_output(cmd).strip().decode('UTF-8')
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 line.strip().decode('UTF-8')
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, 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_output(
['git', 'rev-parse', '--verify', '-q', '--symbolic-full-name', branch]
).strip().decode('UTF-8')
# 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_output(['git', 'config', '--int', 'whenmerged.abbrev']).strip().decode('UTF-8')
)
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.<name>.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, e):
sys.exit(e.message)
refpatterns = []
for value in options.patterns:
try:
refpatterns.append(re.compile(value))
except (re.error, 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, 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, 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, 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:])