installed_progs.t: Python checks stdout too, 150 ok
[sunny256-utils.git] / git-when-merged
blob5d2d95a908f31fb8afd2d09bfed3d091c01d70f5
1 #!/usr/bin/env python
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
27 ancestor.
29 """
31 USAGE = r"""git when-merged [OPTIONS] COMMIT [BRANCH...]
32 """
34 EPILOG = r"""
35 COMMIT
36 a commit whose destiny you would like to determine (this
37 argument is required)
39 BRANCH...
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
44 HEAD is used
46 Examples:
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
56 Configuration:
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 \
67 '^refs/heads/master$'
71 git config whenmerged.releases.pattern \
72 '^refs/remotes/origin/release\-\d+\.\d+$'
74 whenmerged.abbrev
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
78 or --no-abbrev.
80 Based on:
81 http://stackoverflow.com/questions/8475448/find-merge-commit-which-include-a-specific-commit
82 """
84 import sys
85 import re
86 import subprocess
87 import optparse
90 if not (0x02060000 <= sys.hexversion):
91 sys.exit('Python version 2.6 or later is required')
94 # Backwards compatibility:
95 try:
96 from subprocess import CalledProcessError
97 except ImportError:
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
102 self.cmd = cmd
103 self.output = output
104 def __str__(self):
105 return "Command '%s' returned non-zero exit status %d" % (self.cmd, self.returncode)
107 try:
108 from subprocess import check_output
109 except ImportError:
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()
117 if retcode:
118 cmd = kwargs.get("args")
119 if cmd is None:
120 cmd = popenargs[0]
121 try:
122 raise CalledProcessError(retcode, cmd, output=output)
123 except TypeError:
124 # Python 2.6's CalledProcessError has no 'output' kw
125 raise CalledProcessError(retcode, cmd)
126 return output
129 class Failure(Exception):
130 pass
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
139 system path.
141 [1] http://docs.python.org/3/c-api/unicode.html#file-system-encoding
144 if sys.hexversion < 0x3000000:
145 return value
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,)
155 try:
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,))
159 retval = []
160 for value in out.split('\0'):
161 if value:
162 try:
163 retval.append(re.compile(value))
164 except re.error as e:
165 sys.stderr.write(
166 'Error compiling branch pattern %r; ignoring: %s\n'
167 % (value, e.message,)
169 return retval
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
176 to commits.)"""
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']:
189 yield refname
191 retcode = process.wait()
192 if retcode:
193 raise Failure('git for-each-ref failed')
196 def matches_any(refname, refpatterns):
197 return any(
198 refpattern.search(refname)
199 for refpattern in refpatterns
203 def rev_parse(arg, abbrev=None):
204 if abbrev:
205 cmd = ['git', 'rev-parse', '--verify', '-q', '--short=%d' % (abbrev,), arg]
206 else:
207 cmd = ['git', 'rev-parse', '--verify', '-q', arg]
209 try:
210 return check_git_output(cmd).strip()
211 except CalledProcessError:
212 raise Failure('%r is not a valid commit!' % (arg,))
215 def rev_list(*args):
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()
224 if retcode:
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."""
236 try:
237 branch_sha1 = rev_parse(branch)
238 except Failure as e:
239 sys.stdout.write(FORMAT % dict(refname=branch, msg='Is not a valid commit!'))
240 return None
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.'))
248 return None
250 last = None
251 for commit in rev_list('--ancestry-path', '%s..%s' % (commit, branch_sha1,)):
252 if commit in branch_commits:
253 last = commit
255 if not last:
256 sys.stdout.write(FORMAT % dict(refname=branch, msg='Does not contain commit.'))
257 else:
258 if abbrev is not None:
259 msg = rev_parse(last, abbrev=abbrev)
260 else:
261 msg = last
262 sys.stdout.write(FORMAT % dict(refname=branch, msg=msg))
264 return last
267 class Parser(optparse.OptionParser):
268 """An OptionParser that doesn't reflow usage and epilog."""
270 def get_usage(self):
271 return self.usage
273 def format_epilog(self, formatter):
274 return self.epilog
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."""
286 try:
287 full = check_git_output(
288 ['git', 'rev-parse', '--verify', '-q', '--symbolic-full-name', branch]
289 ).strip()
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
292 # not empty.
293 if full:
294 return full
295 except CalledProcessError:
296 pass
298 # branch was not a reference, so just verify that it is valid but
299 # leave it in its original form:
300 rev_parse(branch)
301 return branch
304 def main(args):
305 parser = Parser(
306 prog='git when-merged',
307 description=__doc__,
308 usage=USAGE,
309 epilog=EPILOG,
312 try:
313 default_abbrev = int(
314 check_git_output(['git', 'config', '--int', 'whenmerged.abbrev']).strip()
316 except CalledProcessError:
317 default_abbrev = None
319 parser.add_option(
320 '--pattern', '-p', metavar='PATTERN',
321 action='append', dest='patterns', default=[],
322 help=(
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.'
329 parser.add_option(
330 '--name', '-n', metavar='NAME',
331 action='append', dest='names', default=[],
332 help=(
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).'
338 parser.add_option(
339 '--default', '-s',
340 action='append_const', dest='names', const='default',
341 help='Shorthand for "--name=default".',
343 parser.add_option(
344 '--abbrev', metavar='N',
345 action='store', type='int', default=default_abbrev,
346 help=(
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.'
352 parser.add_option(
353 '--no-abbrev', dest='abbrev', action='store_const', const=None,
354 help='Do not abbreviate commit SHA1s.',
356 parser.add_option(
357 '--diff', '-d', action='store_true', default=False,
358 help='Show the diff for the merge commit.',
360 parser.add_option(
361 '--visualize', '-v', action='store_true', default=False,
362 help='Visualize the merge commit using gitk.',
365 (options, args) = parser.parse_args(args)
367 if not 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
373 commit = args.pop(0)
374 # Convert commit into a SHA1:
375 try:
376 commit = rev_parse(commit)
377 except Failure as e:
378 sys.exit(e.message)
380 refpatterns = []
382 for value in options.patterns:
383 try:
384 refpatterns.append(re.compile(value))
385 except re.error as e:
386 sys.stderr.write(
387 'Error compiling pattern %r; ignoring: %s\n'
388 % (value, e.message,)
391 for value in options.names:
392 try:
393 refpatterns.extend(read_refpatterns(value))
394 except Failure as e:
395 sys.exit(e.message)
397 branches = set()
399 if refpatterns:
400 branches.update(
401 refname
402 for refname in iter_commit_refs()
403 if matches_any(refname, refpatterns)
406 for branch in args:
407 try:
408 branches.add(get_full_name(branch))
409 except Failure as e:
410 sys.exit(e.message)
412 if not branches:
413 branches.add(get_full_name('HEAD'))
415 for branch in sorted(branches):
416 try:
417 merge = find_merge(commit, branch, options.abbrev)
418 except Failure as e:
419 sys.stderr.write('%s\n' % (e.message,))
420 continue
422 if merge:
423 if options.diff:
424 subprocess.check_call(['git', 'show', merge])
426 if options.visualize:
427 subprocess.check_call(['gitk', '--all', '--select-commit=%s' % (merge,)])
430 main(sys.argv[1:])