2 # -*- mode: python; coding: utf-8 -*-
4 # Copyright (c) 2013 Michael Haggerty
6 # This program is free software; you can redistribute it and/or
7 # modify it under the terms of the GNU General Public License
8 # as published by the Free Software Foundation; either version 2
9 # of the License, or (at your option) any later version.
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
16 # You should have received a copy of the GNU General Public License
17 # along with this program; if not, see <http://www.gnu.org/licenses/>
19 # Run "git when-merged --help for the documentation.
20 # See https://github.com/mhagger/git-when-merged for the project.
22 """Find when a commit was merged into one or more branches.
24 Find the merge commit that brought COMMIT into the specified
25 BRANCH(es). Specificially, look for the oldest commit on the
26 first-parent history of BRANCH that contains the COMMIT as an
31 USAGE
= r
"""git when-merged [OPTIONS] COMMIT [BRANCH...]
36 a commit whose destiny you would like to determine (this
40 the destination branches into which <commit> might have been
41 merged. (Actually, BRANCH can be an arbitrary commit, specified
42 in any way that is understood by git-rev-parse(1).) If neither
43 <branch> nor -p/--pattern nor -s/--default is specified, then
47 git when-merged 0a1b # Find merge into current branch
48 git when-merged 0a1b feature-1 feature-2 # Find merge into given branches
49 git when-merged 0a1b -p feature-[0-9]+ # Specify branches by regex
50 git when-merged 0a1b -n releases # Use whenmerged.releases.pattern
51 git when-merged 0a1b -s # Use whenmerged.default.pattern
53 git when-merged 0a1b -d feature-1 # Show diff for each merge commit
54 git when-merged 0a1b -v feature-1 # Display merge commit in gitk
57 whenmerged.<name>.pattern
58 Regular expressions that match reference names for the pattern
59 called <name>. A regexp is sought in the full reference name,
60 in the form "refs/heads/master". This option can be
61 multivalued, in which case references matching any of the
62 patterns are considered. Typically you will use pattern(s) that
63 match master and/or significant release branches, or perhaps
64 their remote-tracking equivalents. For example,
66 git config whenmerged.default.pattern \
71 git config whenmerged.releases.pattern \
72 '^refs/remotes/origin/release\-\d+\.\d+$'
75 If this value is set to a positive integer, then Git SHA1s are
76 abbreviated to this number of characters (or longer if needed to
77 avoid ambiguity). This value can be overridden using --abbrev=N
81 http://stackoverflow.com/questions/8475448/find-merge-commit-which-include-a-specific-commit
90 if not (0x02060000 <= sys
.hexversion
):
91 sys
.exit('Python version 2.6 or later is required')
94 # Backwards compatibility:
96 from subprocess
import CalledProcessError
98 # Use definition from Python 2.7 subprocess module:
99 class CalledProcessError(Exception):
100 def __init__(self
, returncode
, cmd
, output
=None):
101 self
.returncode
= returncode
105 return "Command '%s' returned non-zero exit status %d" % (self
.cmd
, self
.returncode
)
108 from subprocess
import check_output
110 # Use definition from Python 2.7 subprocess module:
111 def check_output(*popenargs
, **kwargs
):
112 if 'stdout' in kwargs
:
113 raise ValueError('stdout argument not allowed, it will be overridden.')
114 process
= subprocess
.Popen(stdout
=subprocess
.PIPE
, *popenargs
, **kwargs
)
115 output
, unused_err
= process
.communicate()
116 retcode
= process
.poll()
118 cmd
= kwargs
.get("args")
122 raise CalledProcessError(retcode
, cmd
, output
=output
)
124 # Python 2.6's CalledProcessError has no 'output' kw
125 raise CalledProcessError(retcode
, cmd
)
129 class Failure(Exception):
133 def _decode_output(value
):
134 """Decodes Git output into a unicode string.
136 On Python 2 this is a no-op; on Python 3 we decode the string as
137 suggested by [1] since we know that Git treats paths as just a sequence
138 of bytes and all of the output we ask Git for is expected to be a file
141 [1] http://docs.python.org/3/c-api/unicode.html#file-system-encoding
144 if sys
.hexversion
< 0x3000000:
146 return value
.decode(sys
.getfilesystemencoding(), 'surrogateescape')
149 def check_git_output(*popenargs
, **kwargs
):
150 return _decode_output(check_output(*popenargs
, **kwargs
))
153 def read_refpatterns(name
):
154 key
= 'whenmerged.%s.pattern' % (name
,)
156 out
= check_git_output(['git', 'config', '--get-all', '--null', key
])
157 except CalledProcessError
:
158 raise Failure('There is no configuration setting for %r!' % (key
,))
160 for value
in out
.split('\0'):
163 retval
.append(re
.compile(value
))
164 except re
.error
as e
:
166 'Error compiling branch pattern %r; ignoring: %s\n'
167 % (value
, e
.message
,)
172 def iter_commit_refs():
173 """Iterate over the names of references that refer to commits.
175 (This includes references that refer to annotated tags that refer
178 process
= subprocess
.Popen(
180 'git', 'for-each-ref',
181 '--format=%(refname) %(objecttype) %(*objecttype)',
183 stdout
=subprocess
.PIPE
,
185 for line
in process
.stdout
:
186 words
= _decode_output(line
).strip().split()
187 refname
= words
.pop(0)
188 if words
== ['commit'] or words
== ['tag', 'commit']:
191 retcode
= process
.wait()
193 raise Failure('git for-each-ref failed')
196 def matches_any(refname
, refpatterns
):
198 refpattern
.search(refname
)
199 for refpattern
in refpatterns
203 def rev_parse(arg
, abbrev
=None):
205 cmd
= ['git', 'rev-parse', '--verify', '-q', '--short=%d' % (abbrev
,), arg
]
207 cmd
= ['git', 'rev-parse', '--verify', '-q', arg
]
210 return check_git_output(cmd
).strip()
211 except CalledProcessError
:
212 raise Failure('%r is not a valid commit!' % (arg
,))
216 process
= subprocess
.Popen(
217 ['git', 'rev-list'] + list(args
) + ['--'],
218 stdout
=subprocess
.PIPE
,
220 for line
in process
.stdout
:
221 yield _decode_output(line
).strip()
223 retcode
= process
.wait()
225 raise Failure('git rev-list %s failed' % (' '.join(args
),))
228 FORMAT
= '%(refname)-38s %(msg)s\n'
230 def find_merge(commit
, branch
, abbrev
):
231 """Return the SHA1 of the commit that merged commit into branch.
233 It is assumed that content is always merged in via the second or
234 subsequent parents of a merge commit."""
237 branch_sha1
= rev_parse(branch
)
239 sys
.stdout
.write(FORMAT
% dict(refname
=branch
, msg
='Is not a valid commit!'))
242 branch_commits
= set(
243 rev_list('--first-parent', branch_sha1
, '--not', '%s^@' % (commit
,))
246 if commit
in branch_commits
:
247 sys
.stdout
.write(FORMAT
% dict(refname
=branch
, msg
='Commit is directly on this branch.'))
251 for commit
in rev_list('--ancestry-path', '%s..%s' % (commit
, branch_sha1
,)):
252 if commit
in branch_commits
:
256 sys
.stdout
.write(FORMAT
% dict(refname
=branch
, msg
='Does not contain commit.'))
258 if abbrev
is not None:
259 msg
= rev_parse(last
, abbrev
=abbrev
)
262 sys
.stdout
.write(FORMAT
% dict(refname
=branch
, msg
=msg
))
267 class Parser(optparse
.OptionParser
):
268 """An OptionParser that doesn't reflow usage and epilog."""
273 def format_epilog(self
, formatter
):
277 def get_full_name(branch
):
278 """Return the full name of the specified commit.
280 If branch is a symbolic reference, return the name of the
281 reference that it refers to. If it is an abbreviated reference
282 name (e.g., "master"), return the full reference name (e.g.,
283 "refs/heads/master"). Otherwise, just verify that it is valid,
284 but return the original value."""
287 full
= check_git_output(
288 ['git', 'rev-parse', '--verify', '-q', '--symbolic-full-name', branch
]
290 # The above call exits successfully, with no output, if branch
291 # is not a reference at all. So only use the value if it is
295 except CalledProcessError
:
298 # branch was not a reference, so just verify that it is valid but
299 # leave it in its original form:
306 prog
='git when-merged',
313 default_abbrev
= int(
314 check_git_output(['git', 'config', '--int', 'whenmerged.abbrev']).strip()
316 except CalledProcessError
:
317 default_abbrev
= None
320 '--pattern', '-p', metavar
='PATTERN',
321 action
='append', dest
='patterns', default
=[],
323 'Show when COMMIT was merged to the references matching '
324 'the specified regexp. If the regexp has parentheses for '
325 'grouping, then display in the output the part of the '
326 'reference name matching the first group.'
330 '--name', '-n', metavar
='NAME',
331 action
='append', dest
='names', default
=[],
333 'Show when COMMIT was merged to the references matching the '
334 'configured pattern(s) with the given name (see '
335 'whenmerged.<name>.pattern below under CONFIGURATION).'
340 action
='append_const', dest
='names', const
='default',
341 help='Shorthand for "--name=default".',
344 '--abbrev', metavar
='N',
345 action
='store', type='int', default
=default_abbrev
,
347 'Abbreviate commit SHA1s to the specified number of characters '
348 '(or more if needed to avoid ambiguity). '
349 'See also whenmerged.abbrev below under CONFIGURATION.'
353 '--no-abbrev', dest
='abbrev', action
='store_const', const
=None,
354 help='Do not abbreviate commit SHA1s.',
357 '--diff', '-d', action
='store_true', default
=False,
358 help='Show the diff for the merge commit.',
361 '--visualize', '-v', action
='store_true', default
=False,
362 help='Visualize the merge commit using gitk.',
365 (options
, args
) = parser
.parse_args(args
)
368 parser
.error('You must specify a COMMIT argument')
370 if options
.abbrev
is not None and options
.abbrev
<= 0:
371 options
.abbrev
= None
374 # Convert commit into a SHA1:
376 commit
= rev_parse(commit
)
382 for value
in options
.patterns
:
384 refpatterns
.append(re
.compile(value
))
385 except re
.error
as e
:
387 'Error compiling pattern %r; ignoring: %s\n'
388 % (value
, e
.message
,)
391 for value
in options
.names
:
393 refpatterns
.extend(read_refpatterns(value
))
402 for refname
in iter_commit_refs()
403 if matches_any(refname
, refpatterns
)
408 branches
.add(get_full_name(branch
))
413 branches
.add(get_full_name('HEAD'))
415 for branch
in sorted(branches
):
417 merge
= find_merge(commit
, branch
, options
.abbrev
)
419 sys
.stderr
.write('%s\n' % (e
.message
,))
424 subprocess
.check_call(['git', 'show', merge
])
426 if options
.visualize
:
427 subprocess
.check_call(['gitk', '--all', '--select-commit=%s' % (merge
,)])