Merge branch 'mh/packed-ref-store-prep'
[git] / contrib / hooks / multimail / git_multimail.py
1 #! /usr/bin/env python
2
3 __version__ = '1.4.0'
4
5 # Copyright (c) 2015-2016 Matthieu Moy and others
6 # Copyright (c) 2012-2014 Michael Haggerty and others
7 # Derived from contrib/hooks/post-receive-email, which is
8 # Copyright (c) 2007 Andy Parkins
9 # and also includes contributions by other authors.
10 #
11 # This file is part of git-multimail.
12 #
13 # git-multimail is free software: you can redistribute it and/or
14 # modify it under the terms of the GNU General Public License version
15 # 2 as published by the Free Software Foundation.
16 #
17 # This program is distributed in the hope that it will be useful, but
18 # WITHOUT ANY WARRANTY; without even the implied warranty of
19 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
20 # General Public License for more details.
21 #
22 # You should have received a copy of the GNU General Public License
23 # along with this program.  If not, see
24 # <http://www.gnu.org/licenses/>.
25
26 """Generate notification emails for pushes to a git repository.
27
28 This hook sends emails describing changes introduced by pushes to a
29 git repository.  For each reference that was changed, it emits one
30 ReferenceChange email summarizing how the reference was changed,
31 followed by one Revision email for each new commit that was introduced
32 by the reference change.
33
34 Each commit is announced in exactly one Revision email.  If the same
35 commit is merged into another branch in the same or a later push, then
36 the ReferenceChange email will list the commit's SHA1 and its one-line
37 summary, but no new Revision email will be generated.
38
39 This script is designed to be used as a "post-receive" hook in a git
40 repository (see githooks(5)).  It can also be used as an "update"
41 script, but this usage is not completely reliable and is deprecated.
42
43 To help with debugging, this script accepts a --stdout option, which
44 causes the emails to be written to standard output rather than sent
45 using sendmail.
46
47 See the accompanying README file for the complete documentation.
48
49 """
50
51 import sys
52 import os
53 import re
54 import bisect
55 import socket
56 import subprocess
57 import shlex
58 import optparse
59 import logging
60 import smtplib
61 try:
62     import ssl
63 except ImportError:
64     # Python < 2.6 do not have ssl, but that's OK if we don't use it.
65     pass
66 import time
67 import cgi
68
69 PYTHON3 = sys.version_info >= (3, 0)
70
71 if sys.version_info <= (2, 5):
72     def all(iterable):
73         for element in iterable:
74             if not element:
75                 return False
76             return True
77
78
79 def is_ascii(s):
80     return all(ord(c) < 128 and ord(c) > 0 for c in s)
81
82
83 if PYTHON3:
84     def is_string(s):
85         return isinstance(s, str)
86
87     def str_to_bytes(s):
88         return s.encode(ENCODING)
89
90     def bytes_to_str(s, errors='strict'):
91         return s.decode(ENCODING, errors)
92
93     unicode = str
94
95     def write_str(f, msg):
96         # Try outputing with the default encoding. If it fails,
97         # try UTF-8.
98         try:
99             f.buffer.write(msg.encode(sys.getdefaultencoding()))
100         except UnicodeEncodeError:
101             f.buffer.write(msg.encode(ENCODING))
102
103     def read_line(f):
104         # Try reading with the default encoding. If it fails,
105         # try UTF-8.
106         out = f.buffer.readline()
107         try:
108             return out.decode(sys.getdefaultencoding())
109         except UnicodeEncodeError:
110             return out.decode(ENCODING)
111 else:
112     def is_string(s):
113         try:
114             return isinstance(s, basestring)
115         except NameError:  # Silence Pyflakes warning
116             raise
117
118     def str_to_bytes(s):
119         return s
120
121     def bytes_to_str(s, errors='strict'):
122         return s
123
124     def write_str(f, msg):
125         f.write(msg)
126
127     def read_line(f):
128         return f.readline()
129
130     def next(it):
131         return it.next()
132
133
134 try:
135     from email.charset import Charset
136     from email.utils import make_msgid
137     from email.utils import getaddresses
138     from email.utils import formataddr
139     from email.utils import formatdate
140     from email.header import Header
141 except ImportError:
142     # Prior to Python 2.5, the email module used different names:
143     from email.Charset import Charset
144     from email.Utils import make_msgid
145     from email.Utils import getaddresses
146     from email.Utils import formataddr
147     from email.Utils import formatdate
148     from email.Header import Header
149
150
151 DEBUG = False
152
153 ZEROS = '0' * 40
154 LOGBEGIN = '- Log -----------------------------------------------------------------\n'
155 LOGEND = '-----------------------------------------------------------------------\n'
156
157 ADDR_HEADERS = set(['from', 'to', 'cc', 'bcc', 'reply-to', 'sender'])
158
159 # It is assumed in many places that the encoding is uniformly UTF-8,
160 # so changing these constants is unsupported.  But define them here
161 # anyway, to make it easier to find (at least most of) the places
162 # where the encoding is important.
163 (ENCODING, CHARSET) = ('UTF-8', 'utf-8')
164
165
166 REF_CREATED_SUBJECT_TEMPLATE = (
167     '%(emailprefix)s%(refname_type)s %(short_refname)s created'
168     ' (now %(newrev_short)s)'
169     )
170 REF_UPDATED_SUBJECT_TEMPLATE = (
171     '%(emailprefix)s%(refname_type)s %(short_refname)s updated'
172     ' (%(oldrev_short)s -> %(newrev_short)s)'
173     )
174 REF_DELETED_SUBJECT_TEMPLATE = (
175     '%(emailprefix)s%(refname_type)s %(short_refname)s deleted'
176     ' (was %(oldrev_short)s)'
177     )
178
179 COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE = (
180     '%(emailprefix)s%(refname_type)s %(short_refname)s updated: %(oneline)s'
181     )
182
183 REFCHANGE_HEADER_TEMPLATE = """\
184 Date: %(send_date)s
185 To: %(recipients)s
186 Subject: %(subject)s
187 MIME-Version: 1.0
188 Content-Type: text/%(contenttype)s; charset=%(charset)s
189 Content-Transfer-Encoding: 8bit
190 Message-ID: %(msgid)s
191 From: %(fromaddr)s
192 Reply-To: %(reply_to)s
193 X-Git-Host: %(fqdn)s
194 X-Git-Repo: %(repo_shortname)s
195 X-Git-Refname: %(refname)s
196 X-Git-Reftype: %(refname_type)s
197 X-Git-Oldrev: %(oldrev)s
198 X-Git-Newrev: %(newrev)s
199 X-Git-NotificationType: ref_changed
200 X-Git-Multimail-Version: %(multimail_version)s
201 Auto-Submitted: auto-generated
202 """
203
204 REFCHANGE_INTRO_TEMPLATE = """\
205 This is an automated email from the git hooks/post-receive script.
206
207 %(pusher)s pushed a change to %(refname_type)s %(short_refname)s
208 in repository %(repo_shortname)s.
209
210 """
211
212
213 FOOTER_TEMPLATE = """\
214
215 -- \n\
216 To stop receiving notification emails like this one, please contact
217 %(administrator)s.
218 """
219
220
221 REWIND_ONLY_TEMPLATE = """\
222 This update removed existing revisions from the reference, leaving the
223 reference pointing at a previous point in the repository history.
224
225  * -- * -- N   %(refname)s (%(newrev_short)s)
226             \\
227              O -- O -- O   (%(oldrev_short)s)
228
229 Any revisions marked "omit" are not gone; other references still
230 refer to them.  Any revisions marked "discard" are gone forever.
231 """
232
233
234 NON_FF_TEMPLATE = """\
235 This update added new revisions after undoing existing revisions.
236 That is to say, some revisions that were in the old version of the
237 %(refname_type)s are not in the new version.  This situation occurs
238 when a user --force pushes a change and generates a repository
239 containing something like this:
240
241  * -- * -- B -- O -- O -- O   (%(oldrev_short)s)
242             \\
243              N -- N -- N   %(refname)s (%(newrev_short)s)
244
245 You should already have received notification emails for all of the O
246 revisions, and so the following emails describe only the N revisions
247 from the common base, B.
248
249 Any revisions marked "omit" are not gone; other references still
250 refer to them.  Any revisions marked "discard" are gone forever.
251 """
252
253
254 NO_NEW_REVISIONS_TEMPLATE = """\
255 No new revisions were added by this update.
256 """
257
258
259 DISCARDED_REVISIONS_TEMPLATE = """\
260 This change permanently discards the following revisions:
261 """
262
263
264 NO_DISCARDED_REVISIONS_TEMPLATE = """\
265 The revisions that were on this %(refname_type)s are still contained in
266 other references; therefore, this change does not discard any commits
267 from the repository.
268 """
269
270
271 NEW_REVISIONS_TEMPLATE = """\
272 The %(tot)s revisions listed above as "new" are entirely new to this
273 repository and will be described in separate emails.  The revisions
274 listed as "add" were already present in the repository and have only
275 been added to this reference.
276
277 """
278
279
280 TAG_CREATED_TEMPLATE = """\
281       at %(newrev_short)-8s (%(newrev_type)s)
282 """
283
284
285 TAG_UPDATED_TEMPLATE = """\
286 *** WARNING: tag %(short_refname)s was modified! ***
287
288     from %(oldrev_short)-8s (%(oldrev_type)s)
289       to %(newrev_short)-8s (%(newrev_type)s)
290 """
291
292
293 TAG_DELETED_TEMPLATE = """\
294 *** WARNING: tag %(short_refname)s was deleted! ***
295
296 """
297
298
299 # The template used in summary tables.  It looks best if this uses the
300 # same alignment as TAG_CREATED_TEMPLATE and TAG_UPDATED_TEMPLATE.
301 BRIEF_SUMMARY_TEMPLATE = """\
302 %(action)8s %(rev_short)-8s %(text)s
303 """
304
305
306 NON_COMMIT_UPDATE_TEMPLATE = """\
307 This is an unusual reference change because the reference did not
308 refer to a commit either before or after the change.  We do not know
309 how to provide full information about this reference change.
310 """
311
312
313 REVISION_HEADER_TEMPLATE = """\
314 Date: %(send_date)s
315 To: %(recipients)s
316 Cc: %(cc_recipients)s
317 Subject: %(emailprefix)s%(num)02d/%(tot)02d: %(oneline)s
318 MIME-Version: 1.0
319 Content-Type: text/%(contenttype)s; charset=%(charset)s
320 Content-Transfer-Encoding: 8bit
321 From: %(fromaddr)s
322 Reply-To: %(reply_to)s
323 In-Reply-To: %(reply_to_msgid)s
324 References: %(reply_to_msgid)s
325 X-Git-Host: %(fqdn)s
326 X-Git-Repo: %(repo_shortname)s
327 X-Git-Refname: %(refname)s
328 X-Git-Reftype: %(refname_type)s
329 X-Git-Rev: %(rev)s
330 X-Git-NotificationType: diff
331 X-Git-Multimail-Version: %(multimail_version)s
332 Auto-Submitted: auto-generated
333 """
334
335 REVISION_INTRO_TEMPLATE = """\
336 This is an automated email from the git hooks/post-receive script.
337
338 %(pusher)s pushed a commit to %(refname_type)s %(short_refname)s
339 in repository %(repo_shortname)s.
340
341 """
342
343 LINK_TEXT_TEMPLATE = """\
344 View the commit online:
345 %(browse_url)s
346
347 """
348
349 LINK_HTML_TEMPLATE = """\
350 <p><a href="%(browse_url)s">View the commit online</a>.</p>
351 """
352
353
354 REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE
355
356
357 # Combined, meaning refchange+revision email (for single-commit additions)
358 COMBINED_HEADER_TEMPLATE = """\
359 Date: %(send_date)s
360 To: %(recipients)s
361 Subject: %(subject)s
362 MIME-Version: 1.0
363 Content-Type: text/%(contenttype)s; charset=%(charset)s
364 Content-Transfer-Encoding: 8bit
365 Message-ID: %(msgid)s
366 From: %(fromaddr)s
367 Reply-To: %(reply_to)s
368 X-Git-Host: %(fqdn)s
369 X-Git-Repo: %(repo_shortname)s
370 X-Git-Refname: %(refname)s
371 X-Git-Reftype: %(refname_type)s
372 X-Git-Oldrev: %(oldrev)s
373 X-Git-Newrev: %(newrev)s
374 X-Git-Rev: %(rev)s
375 X-Git-NotificationType: ref_changed_plus_diff
376 X-Git-Multimail-Version: %(multimail_version)s
377 Auto-Submitted: auto-generated
378 """
379
380 COMBINED_INTRO_TEMPLATE = """\
381 This is an automated email from the git hooks/post-receive script.
382
383 %(pusher)s pushed a commit to %(refname_type)s %(short_refname)s
384 in repository %(repo_shortname)s.
385
386 """
387
388 COMBINED_FOOTER_TEMPLATE = FOOTER_TEMPLATE
389
390
391 class CommandError(Exception):
392     def __init__(self, cmd, retcode):
393         self.cmd = cmd
394         self.retcode = retcode
395         Exception.__init__(
396             self,
397             'Command "%s" failed with retcode %s' % (' '.join(cmd), retcode,)
398             )
399
400
401 class ConfigurationException(Exception):
402     pass
403
404
405 # The "git" program (this could be changed to include a full path):
406 GIT_EXECUTABLE = 'git'
407
408
409 # How "git" should be invoked (including global arguments), as a list
410 # of words.  This variable is usually initialized automatically by
411 # read_git_output() via choose_git_command(), but if a value is set
412 # here then it will be used unconditionally.
413 GIT_CMD = None
414
415
416 def choose_git_command():
417     """Decide how to invoke git, and record the choice in GIT_CMD."""
418
419     global GIT_CMD
420
421     if GIT_CMD is None:
422         try:
423             # Check to see whether the "-c" option is accepted (it was
424             # only added in Git 1.7.2).  We don't actually use the
425             # output of "git --version", though if we needed more
426             # specific version information this would be the place to
427             # do it.
428             cmd = [GIT_EXECUTABLE, '-c', 'foo.bar=baz', '--version']
429             read_output(cmd)
430             GIT_CMD = [GIT_EXECUTABLE, '-c', 'i18n.logoutputencoding=%s' % (ENCODING,)]
431         except CommandError:
432             GIT_CMD = [GIT_EXECUTABLE]
433
434
435 def read_git_output(args, input=None, keepends=False, **kw):
436     """Read the output of a Git command."""
437
438     if GIT_CMD is None:
439         choose_git_command()
440
441     return read_output(GIT_CMD + args, input=input, keepends=keepends, **kw)
442
443
444 def read_output(cmd, input=None, keepends=False, **kw):
445     if input:
446         stdin = subprocess.PIPE
447         input = str_to_bytes(input)
448     else:
449         stdin = None
450     errors = 'strict'
451     if 'errors' in kw:
452         errors = kw['errors']
453         del kw['errors']
454     p = subprocess.Popen(
455         tuple(str_to_bytes(w) for w in cmd),
456         stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw
457         )
458     (out, err) = p.communicate(input)
459     out = bytes_to_str(out, errors=errors)
460     retcode = p.wait()
461     if retcode:
462         raise CommandError(cmd, retcode)
463     if not keepends:
464         out = out.rstrip('\n\r')
465     return out
466
467
468 def read_git_lines(args, keepends=False, **kw):
469     """Return the lines output by Git command.
470
471     Return as single lines, with newlines stripped off."""
472
473     return read_git_output(args, keepends=True, **kw).splitlines(keepends)
474
475
476 def git_rev_list_ish(cmd, spec, args=None, **kw):
477     """Common functionality for invoking a 'git rev-list'-like command.
478
479     Parameters:
480       * cmd is the Git command to run, e.g., 'rev-list' or 'log'.
481       * spec is a list of revision arguments to pass to the named
482         command.  If None, this function returns an empty list.
483       * args is a list of extra arguments passed to the named command.
484       * All other keyword arguments (if any) are passed to the
485         underlying read_git_lines() function.
486
487     Return the output of the Git command in the form of a list, one
488     entry per output line.
489     """
490     if spec is None:
491         return []
492     if args is None:
493         args = []
494     args = [cmd, '--stdin'] + args
495     spec_stdin = ''.join(s + '\n' for s in spec)
496     return read_git_lines(args, input=spec_stdin, **kw)
497
498
499 def git_rev_list(spec, **kw):
500     """Run 'git rev-list' with the given list of revision arguments.
501
502     See git_rev_list_ish() for parameter and return value
503     documentation.
504     """
505     return git_rev_list_ish('rev-list', spec, **kw)
506
507
508 def git_log(spec, **kw):
509     """Run 'git log' with the given list of revision arguments.
510
511     See git_rev_list_ish() for parameter and return value
512     documentation.
513     """
514     return git_rev_list_ish('log', spec, **kw)
515
516
517 def header_encode(text, header_name=None):
518     """Encode and line-wrap the value of an email header field."""
519
520     # Convert to unicode, if required.
521     if not isinstance(text, unicode):
522         text = unicode(text, 'utf-8')
523
524     if is_ascii(text):
525         charset = 'ascii'
526     else:
527         charset = 'utf-8'
528
529     return Header(text, header_name=header_name, charset=Charset(charset)).encode()
530
531
532 def addr_header_encode(text, header_name=None):
533     """Encode and line-wrap the value of an email header field containing
534     email addresses."""
535
536     # Convert to unicode, if required.
537     if not isinstance(text, unicode):
538         text = unicode(text, 'utf-8')
539
540     text = ', '.join(
541         formataddr((header_encode(name), emailaddr))
542         for name, emailaddr in getaddresses([text])
543         )
544
545     if is_ascii(text):
546         charset = 'ascii'
547     else:
548         charset = 'utf-8'
549
550     return Header(text, header_name=header_name, charset=Charset(charset)).encode()
551
552
553 class Config(object):
554     def __init__(self, section, git_config=None):
555         """Represent a section of the git configuration.
556
557         If git_config is specified, it is passed to "git config" in
558         the GIT_CONFIG environment variable, meaning that "git config"
559         will read the specified path rather than the Git default
560         config paths."""
561
562         self.section = section
563         if git_config:
564             self.env = os.environ.copy()
565             self.env['GIT_CONFIG'] = git_config
566         else:
567             self.env = None
568
569     @staticmethod
570     def _split(s):
571         """Split NUL-terminated values."""
572
573         words = s.split('\0')
574         assert words[-1] == ''
575         return words[:-1]
576
577     @staticmethod
578     def add_config_parameters(c):
579         """Add configuration parameters to Git.
580
581         c is either an str or a list of str, each element being of the
582         form 'var=val' or 'var', with the same syntax and meaning as
583         the argument of 'git -c var=val'.
584         """
585         if isinstance(c, str):
586             c = (c,)
587         parameters = os.environ.get('GIT_CONFIG_PARAMETERS', '')
588         if parameters:
589             parameters += ' '
590         # git expects GIT_CONFIG_PARAMETERS to be of the form
591         #    "'name1=value1' 'name2=value2' 'name3=value3'"
592         # including everything inside the double quotes (but not the double
593         # quotes themselves).  Spacing is critical.  Also, if a value contains
594         # a literal single quote that quote must be represented using the
595         # four character sequence: '\''
596         parameters += ' '.join("'" + x.replace("'", "'\\''") + "'" for x in c)
597         os.environ['GIT_CONFIG_PARAMETERS'] = parameters
598
599     def get(self, name, default=None):
600         try:
601             values = self._split(read_git_output(
602                 ['config', '--get', '--null', '%s.%s' % (self.section, name)],
603                 env=self.env, keepends=True,
604                 ))
605             assert len(values) == 1
606             return values[0]
607         except CommandError:
608             return default
609
610     def get_bool(self, name, default=None):
611         try:
612             value = read_git_output(
613                 ['config', '--get', '--bool', '%s.%s' % (self.section, name)],
614                 env=self.env,
615                 )
616         except CommandError:
617             return default
618         return value == 'true'
619
620     def get_all(self, name, default=None):
621         """Read a (possibly multivalued) setting from the configuration.
622
623         Return the result as a list of values, or default if the name
624         is unset."""
625
626         try:
627             return self._split(read_git_output(
628                 ['config', '--get-all', '--null', '%s.%s' % (self.section, name)],
629                 env=self.env, keepends=True,
630                 ))
631         except CommandError:
632             t, e, traceback = sys.exc_info()
633             if e.retcode == 1:
634                 # "the section or key is invalid"; i.e., there is no
635                 # value for the specified key.
636                 return default
637             else:
638                 raise
639
640     def set(self, name, value):
641         read_git_output(
642             ['config', '%s.%s' % (self.section, name), value],
643             env=self.env,
644             )
645
646     def add(self, name, value):
647         read_git_output(
648             ['config', '--add', '%s.%s' % (self.section, name), value],
649             env=self.env,
650             )
651
652     def __contains__(self, name):
653         return self.get_all(name, default=None) is not None
654
655     # We don't use this method anymore internally, but keep it here in
656     # case somebody is calling it from their own code:
657     def has_key(self, name):
658         return name in self
659
660     def unset_all(self, name):
661         try:
662             read_git_output(
663                 ['config', '--unset-all', '%s.%s' % (self.section, name)],
664                 env=self.env,
665                 )
666         except CommandError:
667             t, e, traceback = sys.exc_info()
668             if e.retcode == 5:
669                 # The name doesn't exist, which is what we wanted anyway...
670                 pass
671             else:
672                 raise
673
674     def set_recipients(self, name, value):
675         self.unset_all(name)
676         for pair in getaddresses([value]):
677             self.add(name, formataddr(pair))
678
679
680 def generate_summaries(*log_args):
681     """Generate a brief summary for each revision requested.
682
683     log_args are strings that will be passed directly to "git log" as
684     revision selectors.  Iterate over (sha1_short, subject) for each
685     commit specified by log_args (subject is the first line of the
686     commit message as a string without EOLs)."""
687
688     cmd = [
689         'log', '--abbrev', '--format=%h %s',
690         ] + list(log_args) + ['--']
691     for line in read_git_lines(cmd):
692         yield tuple(line.split(' ', 1))
693
694
695 def limit_lines(lines, max_lines):
696     for (index, line) in enumerate(lines):
697         if index < max_lines:
698             yield line
699
700     if index >= max_lines:
701         yield '... %d lines suppressed ...\n' % (index + 1 - max_lines,)
702
703
704 def limit_linelength(lines, max_linelength):
705     for line in lines:
706         # Don't forget that lines always include a trailing newline.
707         if len(line) > max_linelength + 1:
708             line = line[:max_linelength - 7] + ' [...]\n'
709         yield line
710
711
712 class CommitSet(object):
713     """A (constant) set of object names.
714
715     The set should be initialized with full SHA1 object names.  The
716     __contains__() method returns True iff its argument is an
717     abbreviation of any the names in the set."""
718
719     def __init__(self, names):
720         self._names = sorted(names)
721
722     def __len__(self):
723         return len(self._names)
724
725     def __contains__(self, sha1_abbrev):
726         """Return True iff this set contains sha1_abbrev (which might be abbreviated)."""
727
728         i = bisect.bisect_left(self._names, sha1_abbrev)
729         return i < len(self) and self._names[i].startswith(sha1_abbrev)
730
731
732 class GitObject(object):
733     def __init__(self, sha1, type=None):
734         if sha1 == ZEROS:
735             self.sha1 = self.type = self.commit_sha1 = None
736         else:
737             self.sha1 = sha1
738             self.type = type or read_git_output(['cat-file', '-t', self.sha1])
739
740             if self.type == 'commit':
741                 self.commit_sha1 = self.sha1
742             elif self.type == 'tag':
743                 try:
744                     self.commit_sha1 = read_git_output(
745                         ['rev-parse', '--verify', '%s^0' % (self.sha1,)]
746                         )
747                 except CommandError:
748                     # Cannot deref tag to determine commit_sha1
749                     self.commit_sha1 = None
750             else:
751                 self.commit_sha1 = None
752
753         self.short = read_git_output(['rev-parse', '--short', sha1])
754
755     def get_summary(self):
756         """Return (sha1_short, subject) for this commit."""
757
758         if not self.sha1:
759             raise ValueError('Empty commit has no summary')
760
761         return next(iter(generate_summaries('--no-walk', self.sha1)))
762
763     def __eq__(self, other):
764         return isinstance(other, GitObject) and self.sha1 == other.sha1
765
766     def __hash__(self):
767         return hash(self.sha1)
768
769     def __nonzero__(self):
770         return bool(self.sha1)
771
772     def __bool__(self):
773         """Python 2 backward compatibility"""
774         return self.__nonzero__()
775
776     def __str__(self):
777         return self.sha1 or ZEROS
778
779
780 class Change(object):
781     """A Change that has been made to the Git repository.
782
783     Abstract class from which both Revisions and ReferenceChanges are
784     derived.  A Change knows how to generate a notification email
785     describing itself."""
786
787     def __init__(self, environment):
788         self.environment = environment
789         self._values = None
790         self._contains_html_diff = False
791
792     def _contains_diff(self):
793         # We do contain a diff, should it be rendered in HTML?
794         if self.environment.commit_email_format == "html":
795             self._contains_html_diff = True
796
797     def _compute_values(self):
798         """Return a dictionary {keyword: expansion} for this Change.
799
800         Derived classes overload this method to add more entries to
801         the return value.  This method is used internally by
802         get_values().  The return value should always be a new
803         dictionary."""
804
805         values = self.environment.get_values()
806         fromaddr = self.environment.get_fromaddr(change=self)
807         if fromaddr is not None:
808             values['fromaddr'] = fromaddr
809         values['multimail_version'] = get_version()
810         return values
811
812     # Aliases usable in template strings. Tuple of pairs (destination,
813     # source).
814     VALUES_ALIAS = (
815         ("id", "newrev"),
816         )
817
818     def get_values(self, **extra_values):
819         """Return a dictionary {keyword: expansion} for this Change.
820
821         Return a dictionary mapping keywords to the values that they
822         should be expanded to for this Change (used when interpolating
823         template strings).  If any keyword arguments are supplied, add
824         those to the return value as well.  The return value is always
825         a new dictionary."""
826
827         if self._values is None:
828             self._values = self._compute_values()
829
830         values = self._values.copy()
831         if extra_values:
832             values.update(extra_values)
833
834         for alias, val in self.VALUES_ALIAS:
835             values[alias] = values[val]
836         return values
837
838     def expand(self, template, **extra_values):
839         """Expand template.
840
841         Expand the template (which should be a string) using string
842         interpolation of the values for this Change.  If any keyword
843         arguments are provided, also include those in the keywords
844         available for interpolation."""
845
846         return template % self.get_values(**extra_values)
847
848     def expand_lines(self, template, html_escape_val=False, **extra_values):
849         """Break template into lines and expand each line."""
850
851         values = self.get_values(**extra_values)
852         if html_escape_val:
853             for k in values:
854                 if is_string(values[k]):
855                     values[k] = cgi.escape(values[k], True)
856         for line in template.splitlines(True):
857             yield line % values
858
859     def expand_header_lines(self, template, **extra_values):
860         """Break template into lines and expand each line as an RFC 2822 header.
861
862         Encode values and split up lines that are too long.  Silently
863         skip lines that contain references to unknown variables."""
864
865         values = self.get_values(**extra_values)
866         if self._contains_html_diff:
867             self._content_type = 'html'
868         else:
869             self._content_type = 'plain'
870         values['contenttype'] = self._content_type
871
872         for line in template.splitlines():
873             (name, value) = line.split(': ', 1)
874
875             try:
876                 value = value % values
877             except KeyError:
878                 t, e, traceback = sys.exc_info()
879                 if DEBUG:
880                     self.environment.log_warning(
881                         'Warning: unknown variable %r in the following line; line skipped:\n'
882                         '    %s\n'
883                         % (e.args[0], line,)
884                         )
885             else:
886                 if name.lower() in ADDR_HEADERS:
887                     value = addr_header_encode(value, name)
888                 else:
889                     value = header_encode(value, name)
890                 for splitline in ('%s: %s\n' % (name, value)).splitlines(True):
891                     yield splitline
892
893     def generate_email_header(self):
894         """Generate the RFC 2822 email headers for this Change, a line at a time.
895
896         The output should not include the trailing blank line."""
897
898         raise NotImplementedError()
899
900     def generate_browse_link(self, base_url):
901         """Generate a link to an online repository browser."""
902         return iter(())
903
904     def generate_email_intro(self, html_escape_val=False):
905         """Generate the email intro for this Change, a line at a time.
906
907         The output will be used as the standard boilerplate at the top
908         of the email body."""
909
910         raise NotImplementedError()
911
912     def generate_email_body(self):
913         """Generate the main part of the email body, a line at a time.
914
915         The text in the body might be truncated after a specified
916         number of lines (see multimailhook.emailmaxlines)."""
917
918         raise NotImplementedError()
919
920     def generate_email_footer(self, html_escape_val):
921         """Generate the footer of the email, a line at a time.
922
923         The footer is always included, irrespective of
924         multimailhook.emailmaxlines."""
925
926         raise NotImplementedError()
927
928     def _wrap_for_html(self, lines):
929         """Wrap the lines in HTML <pre> tag when using HTML format.
930
931         Escape special HTML characters and add <pre> and </pre> tags around
932         the given lines if we should be generating HTML as indicated by
933         self._contains_html_diff being set to true.
934         """
935         if self._contains_html_diff:
936             yield "<pre style='margin:0'>\n"
937
938             for line in lines:
939                 yield cgi.escape(line)
940
941             yield '</pre>\n'
942         else:
943             for line in lines:
944                 yield line
945
946     def generate_email(self, push, body_filter=None, extra_header_values={}):
947         """Generate an email describing this change.
948
949         Iterate over the lines (including the header lines) of an
950         email describing this change.  If body_filter is not None,
951         then use it to filter the lines that are intended for the
952         email body.
953
954         The extra_header_values field is received as a dict and not as
955         **kwargs, to allow passing other keyword arguments in the
956         future (e.g. passing extra values to generate_email_intro()"""
957
958         for line in self.generate_email_header(**extra_header_values):
959             yield line
960         yield '\n'
961         html_escape_val = (self.environment.html_in_intro and
962                            self._contains_html_diff)
963         intro = self.generate_email_intro(html_escape_val)
964         if not self.environment.html_in_intro:
965             intro = self._wrap_for_html(intro)
966         for line in intro:
967             yield line
968
969         if self.environment.commitBrowseURL:
970             for line in self.generate_browse_link(self.environment.commitBrowseURL):
971                 yield line
972
973         body = self.generate_email_body(push)
974         if body_filter is not None:
975             body = body_filter(body)
976
977         diff_started = False
978         if self._contains_html_diff:
979             # "white-space: pre" is the default, but we need to
980             # specify it again in case the message is viewed in a
981             # webmail which wraps it in an element setting white-space
982             # to something else (Zimbra does this and sets
983             # white-space: pre-line).
984             yield '<pre style="white-space: pre; background: #F8F8F8">'
985         for line in body:
986             if self._contains_html_diff:
987                 # This is very, very naive. It would be much better to really
988                 # parse the diff, i.e. look at how many lines do we have in
989                 # the hunk headers instead of blindly highlighting everything
990                 # that looks like it might be part of a diff.
991                 bgcolor = ''
992                 fgcolor = ''
993                 if line.startswith('--- a/'):
994                     diff_started = True
995                     bgcolor = 'e0e0ff'
996                 elif line.startswith('diff ') or line.startswith('index '):
997                     diff_started = True
998                     fgcolor = '808080'
999                 elif diff_started:
1000                     if line.startswith('+++ '):
1001                         bgcolor = 'e0e0ff'
1002                     elif line.startswith('@@'):
1003                         bgcolor = 'e0e0e0'
1004                     elif line.startswith('+'):
1005                         bgcolor = 'e0ffe0'
1006                     elif line.startswith('-'):
1007                         bgcolor = 'ffe0e0'
1008                 elif line.startswith('commit '):
1009                     fgcolor = '808000'
1010                 elif line.startswith('    '):
1011                     fgcolor = '404040'
1012
1013                 # Chop the trailing LF, we don't want it inside <pre>.
1014                 line = cgi.escape(line[:-1])
1015
1016                 if bgcolor or fgcolor:
1017                     style = 'display:block; white-space:pre;'
1018                     if bgcolor:
1019                         style += 'background:#' + bgcolor + ';'
1020                     if fgcolor:
1021                         style += 'color:#' + fgcolor + ';'
1022                     # Use a <span style='display:block> to color the
1023                     # whole line. The newline must be inside the span
1024                     # to display properly both in Firefox and in
1025                     # text-based browser.
1026                     line = "<span style='%s'>%s\n</span>" % (style, line)
1027                 else:
1028                     line = line + '\n'
1029
1030             yield line
1031         if self._contains_html_diff:
1032             yield '</pre>'
1033         html_escape_val = (self.environment.html_in_footer and
1034                            self._contains_html_diff)
1035         footer = self.generate_email_footer(html_escape_val)
1036         if not self.environment.html_in_footer:
1037             footer = self._wrap_for_html(footer)
1038         for line in footer:
1039             yield line
1040
1041     def get_specific_fromaddr(self):
1042         """For kinds of Changes which specify it, return the kind-specific
1043         From address to use."""
1044         return None
1045
1046
1047 class Revision(Change):
1048     """A Change consisting of a single git commit."""
1049
1050     CC_RE = re.compile(r'^\s*C[Cc]:\s*(?P<to>[^#]+@[^\s#]*)\s*(#.*)?$')
1051
1052     def __init__(self, reference_change, rev, num, tot):
1053         Change.__init__(self, reference_change.environment)
1054         self.reference_change = reference_change
1055         self.rev = rev
1056         self.change_type = self.reference_change.change_type
1057         self.refname = self.reference_change.refname
1058         self.num = num
1059         self.tot = tot
1060         self.author = read_git_output(['log', '--no-walk', '--format=%aN <%aE>', self.rev.sha1])
1061         self.recipients = self.environment.get_revision_recipients(self)
1062
1063         self.cc_recipients = ''
1064         if self.environment.get_scancommitforcc():
1065             self.cc_recipients = ', '.join(to.strip() for to in self._cc_recipients())
1066             if self.cc_recipients:
1067                 self.environment.log_msg(
1068                     'Add %s to CC for %s' % (self.cc_recipients, self.rev.sha1))
1069
1070     def _cc_recipients(self):
1071         cc_recipients = []
1072         message = read_git_output(['log', '--no-walk', '--format=%b', self.rev.sha1])
1073         lines = message.strip().split('\n')
1074         for line in lines:
1075             m = re.match(self.CC_RE, line)
1076             if m:
1077                 cc_recipients.append(m.group('to'))
1078
1079         return cc_recipients
1080
1081     def _compute_values(self):
1082         values = Change._compute_values(self)
1083
1084         oneline = read_git_output(
1085             ['log', '--format=%s', '--no-walk', self.rev.sha1]
1086             )
1087
1088         max_subject_length = self.environment.get_max_subject_length()
1089         if max_subject_length > 0 and len(oneline) > max_subject_length:
1090             oneline = oneline[:max_subject_length - 6] + ' [...]'
1091
1092         values['rev'] = self.rev.sha1
1093         values['rev_short'] = self.rev.short
1094         values['change_type'] = self.change_type
1095         values['refname'] = self.refname
1096         values['newrev'] = self.rev.sha1
1097         values['short_refname'] = self.reference_change.short_refname
1098         values['refname_type'] = self.reference_change.refname_type
1099         values['reply_to_msgid'] = self.reference_change.msgid
1100         values['num'] = self.num
1101         values['tot'] = self.tot
1102         values['recipients'] = self.recipients
1103         if self.cc_recipients:
1104             values['cc_recipients'] = self.cc_recipients
1105         values['oneline'] = oneline
1106         values['author'] = self.author
1107
1108         reply_to = self.environment.get_reply_to_commit(self)
1109         if reply_to:
1110             values['reply_to'] = reply_to
1111
1112         return values
1113
1114     def generate_email_header(self, **extra_values):
1115         for line in self.expand_header_lines(
1116                 REVISION_HEADER_TEMPLATE, **extra_values
1117                 ):
1118             yield line
1119
1120     def generate_browse_link(self, base_url):
1121         if '%(' not in base_url:
1122             base_url += '%(id)s'
1123         url = "".join(self.expand_lines(base_url))
1124         if self._content_type == 'html':
1125             for line in self.expand_lines(LINK_HTML_TEMPLATE,
1126                                           html_escape_val=True,
1127                                           browse_url=url):
1128                 yield line
1129         elif self._content_type == 'plain':
1130             for line in self.expand_lines(LINK_TEXT_TEMPLATE,
1131                                           html_escape_val=False,
1132                                           browse_url=url):
1133                 yield line
1134         else:
1135             raise NotImplementedError("Content-type %s unsupported. Please report it as a bug.")
1136
1137     def generate_email_intro(self, html_escape_val=False):
1138         for line in self.expand_lines(REVISION_INTRO_TEMPLATE,
1139                                       html_escape_val=html_escape_val):
1140             yield line
1141
1142     def generate_email_body(self, push):
1143         """Show this revision."""
1144
1145         for line in read_git_lines(
1146                 ['log'] + self.environment.commitlogopts + ['-1', self.rev.sha1],
1147                 keepends=True,
1148                 errors='replace'):
1149             if line.startswith('Date:   ') and self.environment.date_substitute:
1150                 yield self.environment.date_substitute + line[len('Date:   '):]
1151             else:
1152                 yield line
1153
1154     def generate_email_footer(self, html_escape_val):
1155         return self.expand_lines(REVISION_FOOTER_TEMPLATE,
1156                                  html_escape_val=html_escape_val)
1157
1158     def generate_email(self, push, body_filter=None, extra_header_values={}):
1159         self._contains_diff()
1160         return Change.generate_email(self, push, body_filter, extra_header_values)
1161
1162     def get_specific_fromaddr(self):
1163         return self.environment.from_commit
1164
1165
1166 class ReferenceChange(Change):
1167     """A Change to a Git reference.
1168
1169     An abstract class representing a create, update, or delete of a
1170     Git reference.  Derived classes handle specific types of reference
1171     (e.g., tags vs. branches).  These classes generate the main
1172     reference change email summarizing the reference change and
1173     whether it caused any any commits to be added or removed.
1174
1175     ReferenceChange objects are usually created using the static
1176     create() method, which has the logic to decide which derived class
1177     to instantiate."""
1178
1179     REF_RE = re.compile(r'^refs\/(?P<area>[^\/]+)\/(?P<shortname>.*)$')
1180
1181     @staticmethod
1182     def create(environment, oldrev, newrev, refname):
1183         """Return a ReferenceChange object representing the change.
1184
1185         Return an object that represents the type of change that is being
1186         made. oldrev and newrev should be SHA1s or ZEROS."""
1187
1188         old = GitObject(oldrev)
1189         new = GitObject(newrev)
1190         rev = new or old
1191
1192         # The revision type tells us what type the commit is, combined with
1193         # the location of the ref we can decide between
1194         #  - working branch
1195         #  - tracking branch
1196         #  - unannotated tag
1197         #  - annotated tag
1198         m = ReferenceChange.REF_RE.match(refname)
1199         if m:
1200             area = m.group('area')
1201             short_refname = m.group('shortname')
1202         else:
1203             area = ''
1204             short_refname = refname
1205
1206         if rev.type == 'tag':
1207             # Annotated tag:
1208             klass = AnnotatedTagChange
1209         elif rev.type == 'commit':
1210             if area == 'tags':
1211                 # Non-annotated tag:
1212                 klass = NonAnnotatedTagChange
1213             elif area == 'heads':
1214                 # Branch:
1215                 klass = BranchChange
1216             elif area == 'remotes':
1217                 # Tracking branch:
1218                 environment.log_warning(
1219                     '*** Push-update of tracking branch %r\n'
1220                     '***  - incomplete email generated.'
1221                     % (refname,)
1222                     )
1223                 klass = OtherReferenceChange
1224             else:
1225                 # Some other reference namespace:
1226                 environment.log_warning(
1227                     '*** Push-update of strange reference %r\n'
1228                     '***  - incomplete email generated.'
1229                     % (refname,)
1230                     )
1231                 klass = OtherReferenceChange
1232         else:
1233             # Anything else (is there anything else?)
1234             environment.log_warning(
1235                 '*** Unknown type of update to %r (%s)\n'
1236                 '***  - incomplete email generated.'
1237                 % (refname, rev.type,)
1238                 )
1239             klass = OtherReferenceChange
1240
1241         return klass(
1242             environment,
1243             refname=refname, short_refname=short_refname,
1244             old=old, new=new, rev=rev,
1245             )
1246
1247     def __init__(self, environment, refname, short_refname, old, new, rev):
1248         Change.__init__(self, environment)
1249         self.change_type = {
1250             (False, True): 'create',
1251             (True, True): 'update',
1252             (True, False): 'delete',
1253             }[bool(old), bool(new)]
1254         self.refname = refname
1255         self.short_refname = short_refname
1256         self.old = old
1257         self.new = new
1258         self.rev = rev
1259         self.msgid = make_msgid()
1260         self.diffopts = environment.diffopts
1261         self.graphopts = environment.graphopts
1262         self.logopts = environment.logopts
1263         self.commitlogopts = environment.commitlogopts
1264         self.showgraph = environment.refchange_showgraph
1265         self.showlog = environment.refchange_showlog
1266
1267         self.header_template = REFCHANGE_HEADER_TEMPLATE
1268         self.intro_template = REFCHANGE_INTRO_TEMPLATE
1269         self.footer_template = FOOTER_TEMPLATE
1270
1271     def _compute_values(self):
1272         values = Change._compute_values(self)
1273
1274         values['change_type'] = self.change_type
1275         values['refname_type'] = self.refname_type
1276         values['refname'] = self.refname
1277         values['short_refname'] = self.short_refname
1278         values['msgid'] = self.msgid
1279         values['recipients'] = self.recipients
1280         values['oldrev'] = str(self.old)
1281         values['oldrev_short'] = self.old.short
1282         values['newrev'] = str(self.new)
1283         values['newrev_short'] = self.new.short
1284
1285         if self.old:
1286             values['oldrev_type'] = self.old.type
1287         if self.new:
1288             values['newrev_type'] = self.new.type
1289
1290         reply_to = self.environment.get_reply_to_refchange(self)
1291         if reply_to:
1292             values['reply_to'] = reply_to
1293
1294         return values
1295
1296     def send_single_combined_email(self, known_added_sha1s):
1297         """Determine if a combined refchange/revision email should be sent
1298
1299         If there is only a single new (non-merge) commit added by a
1300         change, it is useful to combine the ReferenceChange and
1301         Revision emails into one.  In such a case, return the single
1302         revision; otherwise, return None.
1303
1304         This method is overridden in BranchChange."""
1305
1306         return None
1307
1308     def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):
1309         """Generate an email describing this change AND specified revision.
1310
1311         Iterate over the lines (including the header lines) of an
1312         email describing this change.  If body_filter is not None,
1313         then use it to filter the lines that are intended for the
1314         email body.
1315
1316         The extra_header_values field is received as a dict and not as
1317         **kwargs, to allow passing other keyword arguments in the
1318         future (e.g. passing extra values to generate_email_intro()
1319
1320         This method is overridden in BranchChange."""
1321
1322         raise NotImplementedError
1323
1324     def get_subject(self):
1325         template = {
1326             'create': REF_CREATED_SUBJECT_TEMPLATE,
1327             'update': REF_UPDATED_SUBJECT_TEMPLATE,
1328             'delete': REF_DELETED_SUBJECT_TEMPLATE,
1329             }[self.change_type]
1330         return self.expand(template)
1331
1332     def generate_email_header(self, **extra_values):
1333         if 'subject' not in extra_values:
1334             extra_values['subject'] = self.get_subject()
1335
1336         for line in self.expand_header_lines(
1337                 self.header_template, **extra_values
1338                 ):
1339             yield line
1340
1341     def generate_email_intro(self, html_escape_val=False):
1342         for line in self.expand_lines(self.intro_template,
1343                                       html_escape_val=html_escape_val):
1344             yield line
1345
1346     def generate_email_body(self, push):
1347         """Call the appropriate body-generation routine.
1348
1349         Call one of generate_create_summary() /
1350         generate_update_summary() / generate_delete_summary()."""
1351
1352         change_summary = {
1353             'create': self.generate_create_summary,
1354             'delete': self.generate_delete_summary,
1355             'update': self.generate_update_summary,
1356             }[self.change_type](push)
1357         for line in change_summary:
1358             yield line
1359
1360         for line in self.generate_revision_change_summary(push):
1361             yield line
1362
1363     def generate_email_footer(self, html_escape_val):
1364         return self.expand_lines(self.footer_template,
1365                                  html_escape_val=html_escape_val)
1366
1367     def generate_revision_change_graph(self, push):
1368         if self.showgraph:
1369             args = ['--graph'] + self.graphopts
1370             for newold in ('new', 'old'):
1371                 has_newold = False
1372                 spec = push.get_commits_spec(newold, self)
1373                 for line in git_log(spec, args=args, keepends=True):
1374                     if not has_newold:
1375                         has_newold = True
1376                         yield '\n'
1377                         yield 'Graph of %s commits:\n\n' % (
1378                             {'new': 'new', 'old': 'discarded'}[newold],)
1379                     yield '  ' + line
1380                 if has_newold:
1381                     yield '\n'
1382
1383     def generate_revision_change_log(self, new_commits_list):
1384         if self.showlog:
1385             yield '\n'
1386             yield 'Detailed log of new commits:\n\n'
1387             for line in read_git_lines(
1388                     ['log', '--no-walk'] +
1389                     self.logopts +
1390                     new_commits_list +
1391                     ['--'],
1392                     keepends=True,
1393                     ):
1394                 yield line
1395
1396     def generate_new_revision_summary(self, tot, new_commits_list, push):
1397         for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot):
1398             yield line
1399         for line in self.generate_revision_change_graph(push):
1400             yield line
1401         for line in self.generate_revision_change_log(new_commits_list):
1402             yield line
1403
1404     def generate_revision_change_summary(self, push):
1405         """Generate a summary of the revisions added/removed by this change."""
1406
1407         if self.new.commit_sha1 and not self.old.commit_sha1:
1408             # A new reference was created.  List the new revisions
1409             # brought by the new reference (i.e., those revisions that
1410             # were not in the repository before this reference
1411             # change).
1412             sha1s = list(push.get_new_commits(self))
1413             sha1s.reverse()
1414             tot = len(sha1s)
1415             new_revisions = [
1416                 Revision(self, GitObject(sha1), num=i + 1, tot=tot)
1417                 for (i, sha1) in enumerate(sha1s)
1418                 ]
1419
1420             if new_revisions:
1421                 yield self.expand('This %(refname_type)s includes the following new commits:\n')
1422                 yield '\n'
1423                 for r in new_revisions:
1424                     (sha1, subject) = r.rev.get_summary()
1425                     yield r.expand(
1426                         BRIEF_SUMMARY_TEMPLATE, action='new', text=subject,
1427                         )
1428                 yield '\n'
1429                 for line in self.generate_new_revision_summary(
1430                         tot, [r.rev.sha1 for r in new_revisions], push):
1431                     yield line
1432             else:
1433                 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
1434                     yield line
1435
1436         elif self.new.commit_sha1 and self.old.commit_sha1:
1437             # A reference was changed to point at a different commit.
1438             # List the revisions that were removed and/or added *from
1439             # that reference* by this reference change, along with a
1440             # diff between the trees for its old and new values.
1441
1442             # List of the revisions that were added to the branch by
1443             # this update.  Note this list can include revisions that
1444             # have already had notification emails; we want such
1445             # revisions in the summary even though we will not send
1446             # new notification emails for them.
1447             adds = list(generate_summaries(
1448                 '--topo-order', '--reverse', '%s..%s'
1449                 % (self.old.commit_sha1, self.new.commit_sha1,)
1450                 ))
1451
1452             # List of the revisions that were removed from the branch
1453             # by this update.  This will be empty except for
1454             # non-fast-forward updates.
1455             discards = list(generate_summaries(
1456                 '%s..%s' % (self.new.commit_sha1, self.old.commit_sha1,)
1457                 ))
1458
1459             if adds:
1460                 new_commits_list = push.get_new_commits(self)
1461             else:
1462                 new_commits_list = []
1463             new_commits = CommitSet(new_commits_list)
1464
1465             if discards:
1466                 discarded_commits = CommitSet(push.get_discarded_commits(self))
1467             else:
1468                 discarded_commits = CommitSet([])
1469
1470             if discards and adds:
1471                 for (sha1, subject) in discards:
1472                     if sha1 in discarded_commits:
1473                         action = 'discard'
1474                     else:
1475                         action = 'omit'
1476                     yield self.expand(
1477                         BRIEF_SUMMARY_TEMPLATE, action=action,
1478                         rev_short=sha1, text=subject,
1479                         )
1480                 for (sha1, subject) in adds:
1481                     if sha1 in new_commits:
1482                         action = 'new'
1483                     else:
1484                         action = 'add'
1485                     yield self.expand(
1486                         BRIEF_SUMMARY_TEMPLATE, action=action,
1487                         rev_short=sha1, text=subject,
1488                         )
1489                 yield '\n'
1490                 for line in self.expand_lines(NON_FF_TEMPLATE):
1491                     yield line
1492
1493             elif discards:
1494                 for (sha1, subject) in discards:
1495                     if sha1 in discarded_commits:
1496                         action = 'discard'
1497                     else:
1498                         action = 'omit'
1499                     yield self.expand(
1500                         BRIEF_SUMMARY_TEMPLATE, action=action,
1501                         rev_short=sha1, text=subject,
1502                         )
1503                 yield '\n'
1504                 for line in self.expand_lines(REWIND_ONLY_TEMPLATE):
1505                     yield line
1506
1507             elif adds:
1508                 (sha1, subject) = self.old.get_summary()
1509                 yield self.expand(
1510                     BRIEF_SUMMARY_TEMPLATE, action='from',
1511                     rev_short=sha1, text=subject,
1512                     )
1513                 for (sha1, subject) in adds:
1514                     if sha1 in new_commits:
1515                         action = 'new'
1516                     else:
1517                         action = 'add'
1518                     yield self.expand(
1519                         BRIEF_SUMMARY_TEMPLATE, action=action,
1520                         rev_short=sha1, text=subject,
1521                         )
1522
1523             yield '\n'
1524
1525             if new_commits:
1526                 for line in self.generate_new_revision_summary(
1527                         len(new_commits), new_commits_list, push):
1528                     yield line
1529             else:
1530                 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
1531                     yield line
1532                 for line in self.generate_revision_change_graph(push):
1533                     yield line
1534
1535             # The diffstat is shown from the old revision to the new
1536             # revision.  This is to show the truth of what happened in
1537             # this change.  There's no point showing the stat from the
1538             # base to the new revision because the base is effectively a
1539             # random revision at this point - the user will be interested
1540             # in what this revision changed - including the undoing of
1541             # previous revisions in the case of non-fast-forward updates.
1542             yield '\n'
1543             yield 'Summary of changes:\n'
1544             for line in read_git_lines(
1545                     ['diff-tree'] +
1546                     self.diffopts +
1547                     ['%s..%s' % (self.old.commit_sha1, self.new.commit_sha1,)],
1548                     keepends=True,
1549                     ):
1550                 yield line
1551
1552         elif self.old.commit_sha1 and not self.new.commit_sha1:
1553             # A reference was deleted.  List the revisions that were
1554             # removed from the repository by this reference change.
1555
1556             sha1s = list(push.get_discarded_commits(self))
1557             tot = len(sha1s)
1558             discarded_revisions = [
1559                 Revision(self, GitObject(sha1), num=i + 1, tot=tot)
1560                 for (i, sha1) in enumerate(sha1s)
1561                 ]
1562
1563             if discarded_revisions:
1564                 for line in self.expand_lines(DISCARDED_REVISIONS_TEMPLATE):
1565                     yield line
1566                 yield '\n'
1567                 for r in discarded_revisions:
1568                     (sha1, subject) = r.rev.get_summary()
1569                     yield r.expand(
1570                         BRIEF_SUMMARY_TEMPLATE, action='discard', text=subject,
1571                         )
1572                 for line in self.generate_revision_change_graph(push):
1573                     yield line
1574             else:
1575                 for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE):
1576                     yield line
1577
1578         elif not self.old.commit_sha1 and not self.new.commit_sha1:
1579             for line in self.expand_lines(NON_COMMIT_UPDATE_TEMPLATE):
1580                 yield line
1581
1582     def generate_create_summary(self, push):
1583         """Called for the creation of a reference."""
1584
1585         # This is a new reference and so oldrev is not valid
1586         (sha1, subject) = self.new.get_summary()
1587         yield self.expand(
1588             BRIEF_SUMMARY_TEMPLATE, action='at',
1589             rev_short=sha1, text=subject,
1590             )
1591         yield '\n'
1592
1593     def generate_update_summary(self, push):
1594         """Called for the change of a pre-existing branch."""
1595
1596         return iter([])
1597
1598     def generate_delete_summary(self, push):
1599         """Called for the deletion of any type of reference."""
1600
1601         (sha1, subject) = self.old.get_summary()
1602         yield self.expand(
1603             BRIEF_SUMMARY_TEMPLATE, action='was',
1604             rev_short=sha1, text=subject,
1605             )
1606         yield '\n'
1607
1608     def get_specific_fromaddr(self):
1609         return self.environment.from_refchange
1610
1611
1612 class BranchChange(ReferenceChange):
1613     refname_type = 'branch'
1614
1615     def __init__(self, environment, refname, short_refname, old, new, rev):
1616         ReferenceChange.__init__(
1617             self, environment,
1618             refname=refname, short_refname=short_refname,
1619             old=old, new=new, rev=rev,
1620             )
1621         self.recipients = environment.get_refchange_recipients(self)
1622         self._single_revision = None
1623
1624     def send_single_combined_email(self, known_added_sha1s):
1625         if not self.environment.combine_when_single_commit:
1626             return None
1627
1628         # In the sadly-all-too-frequent usecase of people pushing only
1629         # one of their commits at a time to a repository, users feel
1630         # the reference change summary emails are noise rather than
1631         # important signal.  This is because, in this particular
1632         # usecase, there is a reference change summary email for each
1633         # new commit, and all these summaries do is point out that
1634         # there is one new commit (which can readily be inferred by
1635         # the existence of the individual revision email that is also
1636         # sent).  In such cases, our users prefer there to be a combined
1637         # reference change summary/new revision email.
1638         #
1639         # So, if the change is an update and it doesn't discard any
1640         # commits, and it adds exactly one non-merge commit (gerrit
1641         # forces a workflow where every commit is individually merged
1642         # and the git-multimail hook fired off for just this one
1643         # change), then we send a combined refchange/revision email.
1644         try:
1645             # If this change is a reference update that doesn't discard
1646             # any commits...
1647             if self.change_type != 'update':
1648                 return None
1649
1650             if read_git_lines(
1651                     ['merge-base', self.old.sha1, self.new.sha1]
1652                     ) != [self.old.sha1]:
1653                 return None
1654
1655             # Check if this update introduced exactly one non-merge
1656             # commit:
1657
1658             def split_line(line):
1659                 """Split line into (sha1, [parent,...])."""
1660
1661                 words = line.split()
1662                 return (words[0], words[1:])
1663
1664             # Get the new commits introduced by the push as a list of
1665             # (sha1, [parent,...])
1666             new_commits = [
1667                 split_line(line)
1668                 for line in read_git_lines(
1669                     [
1670                         'log', '-3', '--format=%H %P',
1671                         '%s..%s' % (self.old.sha1, self.new.sha1),
1672                         ]
1673                     )
1674                 ]
1675
1676             if not new_commits:
1677                 return None
1678
1679             # If the newest commit is a merge, save it for a later check
1680             # but otherwise ignore it
1681             merge = None
1682             tot = len(new_commits)
1683             if len(new_commits[0][1]) > 1:
1684                 merge = new_commits[0][0]
1685                 del new_commits[0]
1686
1687             # Our primary check: we can't combine if more than one commit
1688             # is introduced.  We also currently only combine if the new
1689             # commit is a non-merge commit, though it may make sense to
1690             # combine if it is a merge as well.
1691             if not (
1692                     len(new_commits) == 1 and
1693                     len(new_commits[0][1]) == 1 and
1694                     new_commits[0][0] in known_added_sha1s
1695                     ):
1696                 return None
1697
1698             # We do not want to combine revision and refchange emails if
1699             # those go to separate locations.
1700             rev = Revision(self, GitObject(new_commits[0][0]), 1, tot)
1701             if rev.recipients != self.recipients:
1702                 return None
1703
1704             # We ignored the newest commit if it was just a merge of the one
1705             # commit being introduced.  But we don't want to ignore that
1706             # merge commit it it involved conflict resolutions.  Check that.
1707             if merge and merge != read_git_output(['diff-tree', '--cc', merge]):
1708                 return None
1709
1710             # We can combine the refchange and one new revision emails
1711             # into one.  Return the Revision that a combined email should
1712             # be sent about.
1713             return rev
1714         except CommandError:
1715             # Cannot determine number of commits in old..new or new..old;
1716             # don't combine reference/revision emails:
1717             return None
1718
1719     def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):
1720         values = revision.get_values()
1721         if extra_header_values:
1722             values.update(extra_header_values)
1723         if 'subject' not in extra_header_values:
1724             values['subject'] = self.expand(COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE, **values)
1725
1726         self._single_revision = revision
1727         self._contains_diff()
1728         self.header_template = COMBINED_HEADER_TEMPLATE
1729         self.intro_template = COMBINED_INTRO_TEMPLATE
1730         self.footer_template = COMBINED_FOOTER_TEMPLATE
1731
1732         def revision_gen_link(base_url):
1733             # revision is used only to generate the body, and
1734             # _content_type is set while generating headers. Get it
1735             # from the BranchChange object.
1736             revision._content_type = self._content_type
1737             return revision.generate_browse_link(base_url)
1738         self.generate_browse_link = revision_gen_link
1739         for line in self.generate_email(push, body_filter, values):
1740             yield line
1741
1742     def generate_email_body(self, push):
1743         '''Call the appropriate body generation routine.
1744
1745         If this is a combined refchange/revision email, the special logic
1746         for handling this combined email comes from this function.  For
1747         other cases, we just use the normal handling.'''
1748
1749         # If self._single_revision isn't set; don't override
1750         if not self._single_revision:
1751             for line in super(BranchChange, self).generate_email_body(push):
1752                 yield line
1753             return
1754
1755         # This is a combined refchange/revision email; we first provide
1756         # some info from the refchange portion, and then call the revision
1757         # generate_email_body function to handle the revision portion.
1758         adds = list(generate_summaries(
1759             '--topo-order', '--reverse', '%s..%s'
1760             % (self.old.commit_sha1, self.new.commit_sha1,)
1761             ))
1762
1763         yield self.expand("The following commit(s) were added to %(refname)s by this push:\n")
1764         for (sha1, subject) in adds:
1765             yield self.expand(
1766                 BRIEF_SUMMARY_TEMPLATE, action='new',
1767                 rev_short=sha1, text=subject,
1768                 )
1769
1770         yield self._single_revision.rev.short + " is described below\n"
1771         yield '\n'
1772
1773         for line in self._single_revision.generate_email_body(push):
1774             yield line
1775
1776
1777 class AnnotatedTagChange(ReferenceChange):
1778     refname_type = 'annotated tag'
1779
1780     def __init__(self, environment, refname, short_refname, old, new, rev):
1781         ReferenceChange.__init__(
1782             self, environment,
1783             refname=refname, short_refname=short_refname,
1784             old=old, new=new, rev=rev,
1785             )
1786         self.recipients = environment.get_announce_recipients(self)
1787         self.show_shortlog = environment.announce_show_shortlog
1788
1789     ANNOTATED_TAG_FORMAT = (
1790         '%(*objectname)\n'
1791         '%(*objecttype)\n'
1792         '%(taggername)\n'
1793         '%(taggerdate)'
1794         )
1795
1796     def describe_tag(self, push):
1797         """Describe the new value of an annotated tag."""
1798
1799         # Use git for-each-ref to pull out the individual fields from
1800         # the tag
1801         [tagobject, tagtype, tagger, tagged] = read_git_lines(
1802             ['for-each-ref', '--format=%s' % (self.ANNOTATED_TAG_FORMAT,), self.refname],
1803             )
1804
1805         yield self.expand(
1806             BRIEF_SUMMARY_TEMPLATE, action='tagging',
1807             rev_short=tagobject, text='(%s)' % (tagtype,),
1808             )
1809         if tagtype == 'commit':
1810             # If the tagged object is a commit, then we assume this is a
1811             # release, and so we calculate which tag this tag is
1812             # replacing
1813             try:
1814                 prevtag = read_git_output(['describe', '--abbrev=0', '%s^' % (self.new,)])
1815             except CommandError:
1816                 prevtag = None
1817             if prevtag:
1818                 yield ' replaces %s\n' % (prevtag,)
1819         else:
1820             prevtag = None
1821             yield '  length %s bytes\n' % (read_git_output(['cat-file', '-s', tagobject]),)
1822
1823         yield '      by %s\n' % (tagger,)
1824         yield '      on %s\n' % (tagged,)
1825         yield '\n'
1826
1827         # Show the content of the tag message; this might contain a
1828         # change log or release notes so is worth displaying.
1829         yield LOGBEGIN
1830         contents = list(read_git_lines(['cat-file', 'tag', self.new.sha1], keepends=True))
1831         contents = contents[contents.index('\n') + 1:]
1832         if contents and contents[-1][-1:] != '\n':
1833             contents.append('\n')
1834         for line in contents:
1835             yield line
1836
1837         if self.show_shortlog and tagtype == 'commit':
1838             # Only commit tags make sense to have rev-list operations
1839             # performed on them
1840             yield '\n'
1841             if prevtag:
1842                 # Show changes since the previous release
1843                 revlist = read_git_output(
1844                     ['rev-list', '--pretty=short', '%s..%s' % (prevtag, self.new,)],
1845                     keepends=True,
1846                     )
1847             else:
1848                 # No previous tag, show all the changes since time
1849                 # began
1850                 revlist = read_git_output(
1851                     ['rev-list', '--pretty=short', '%s' % (self.new,)],
1852                     keepends=True,
1853                     )
1854             for line in read_git_lines(['shortlog'], input=revlist, keepends=True):
1855                 yield line
1856
1857         yield LOGEND
1858         yield '\n'
1859
1860     def generate_create_summary(self, push):
1861         """Called for the creation of an annotated tag."""
1862
1863         for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1864             yield line
1865
1866         for line in self.describe_tag(push):
1867             yield line
1868
1869     def generate_update_summary(self, push):
1870         """Called for the update of an annotated tag.
1871
1872         This is probably a rare event and may not even be allowed."""
1873
1874         for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1875             yield line
1876
1877         for line in self.describe_tag(push):
1878             yield line
1879
1880     def generate_delete_summary(self, push):
1881         """Called when a non-annotated reference is updated."""
1882
1883         for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1884             yield line
1885
1886         yield self.expand('   tag was  %(oldrev_short)s\n')
1887         yield '\n'
1888
1889
1890 class NonAnnotatedTagChange(ReferenceChange):
1891     refname_type = 'tag'
1892
1893     def __init__(self, environment, refname, short_refname, old, new, rev):
1894         ReferenceChange.__init__(
1895             self, environment,
1896             refname=refname, short_refname=short_refname,
1897             old=old, new=new, rev=rev,
1898             )
1899         self.recipients = environment.get_refchange_recipients(self)
1900
1901     def generate_create_summary(self, push):
1902         """Called for the creation of an annotated tag."""
1903
1904         for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1905             yield line
1906
1907     def generate_update_summary(self, push):
1908         """Called when a non-annotated reference is updated."""
1909
1910         for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1911             yield line
1912
1913     def generate_delete_summary(self, push):
1914         """Called when a non-annotated reference is updated."""
1915
1916         for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1917             yield line
1918
1919         for line in ReferenceChange.generate_delete_summary(self, push):
1920             yield line
1921
1922
1923 class OtherReferenceChange(ReferenceChange):
1924     refname_type = 'reference'
1925
1926     def __init__(self, environment, refname, short_refname, old, new, rev):
1927         # We use the full refname as short_refname, because otherwise
1928         # the full name of the reference would not be obvious from the
1929         # text of the email.
1930         ReferenceChange.__init__(
1931             self, environment,
1932             refname=refname, short_refname=refname,
1933             old=old, new=new, rev=rev,
1934             )
1935         self.recipients = environment.get_refchange_recipients(self)
1936
1937
1938 class Mailer(object):
1939     """An object that can send emails."""
1940
1941     def __init__(self, environment):
1942         self.environment = environment
1943
1944     def send(self, lines, to_addrs):
1945         """Send an email consisting of lines.
1946
1947         lines must be an iterable over the lines constituting the
1948         header and body of the email.  to_addrs is a list of recipient
1949         addresses (can be needed even if lines already contains a
1950         "To:" field).  It can be either a string (comma-separated list
1951         of email addresses) or a Python list of individual email
1952         addresses.
1953
1954         """
1955
1956         raise NotImplementedError()
1957
1958
1959 class SendMailer(Mailer):
1960     """Send emails using 'sendmail -oi -t'."""
1961
1962     SENDMAIL_CANDIDATES = [
1963         '/usr/sbin/sendmail',
1964         '/usr/lib/sendmail',
1965         ]
1966
1967     @staticmethod
1968     def find_sendmail():
1969         for path in SendMailer.SENDMAIL_CANDIDATES:
1970             if os.access(path, os.X_OK):
1971                 return path
1972         else:
1973             raise ConfigurationException(
1974                 'No sendmail executable found.  '
1975                 'Try setting multimailhook.sendmailCommand.'
1976                 )
1977
1978     def __init__(self, environment, command=None, envelopesender=None):
1979         """Construct a SendMailer instance.
1980
1981         command should be the command and arguments used to invoke
1982         sendmail, as a list of strings.  If an envelopesender is
1983         provided, it will also be passed to the command, via '-f
1984         envelopesender'."""
1985         super(SendMailer, self).__init__(environment)
1986         if command:
1987             self.command = command[:]
1988         else:
1989             self.command = [self.find_sendmail(), '-oi', '-t']
1990
1991         if envelopesender:
1992             self.command.extend(['-f', envelopesender])
1993
1994     def send(self, lines, to_addrs):
1995         try:
1996             p = subprocess.Popen(self.command, stdin=subprocess.PIPE)
1997         except OSError:
1998             self.environment.get_logger().error(
1999                 '*** Cannot execute command: %s\n' % ' '.join(self.command) +
2000                 '*** %s\n' % sys.exc_info()[1] +
2001                 '*** Try setting multimailhook.mailer to "smtp"\n' +
2002                 '*** to send emails without using the sendmail command.\n'
2003                 )
2004             sys.exit(1)
2005         try:
2006             lines = (str_to_bytes(line) for line in lines)
2007             p.stdin.writelines(lines)
2008         except Exception:
2009             self.environment.get_logger().error(
2010                 '*** Error while generating commit email\n'
2011                 '***  - mail sending aborted.\n'
2012                 )
2013             if hasattr(p, 'terminate'):
2014                 # subprocess.terminate() is not available in Python 2.4
2015                 p.terminate()
2016             else:
2017                 import signal
2018                 os.kill(p.pid, signal.SIGTERM)
2019             raise
2020         else:
2021             p.stdin.close()
2022             retcode = p.wait()
2023             if retcode:
2024                 raise CommandError(self.command, retcode)
2025
2026
2027 class SMTPMailer(Mailer):
2028     """Send emails using Python's smtplib."""
2029
2030     def __init__(self, environment,
2031                  envelopesender, smtpserver,
2032                  smtpservertimeout=10.0, smtpserverdebuglevel=0,
2033                  smtpencryption='none',
2034                  smtpuser='', smtppass='',
2035                  smtpcacerts=''
2036                  ):
2037         super(SMTPMailer, self).__init__(environment)
2038         if not envelopesender:
2039             self.environment.get_logger().error(
2040                 'fatal: git_multimail: cannot use SMTPMailer without a sender address.\n'
2041                 'please set either multimailhook.envelopeSender or user.email\n'
2042                 )
2043             sys.exit(1)
2044         if smtpencryption == 'ssl' and not (smtpuser and smtppass):
2045             raise ConfigurationException(
2046                 'Cannot use SMTPMailer with security option ssl '
2047                 'without options username and password.'
2048                 )
2049         self.envelopesender = envelopesender
2050         self.smtpserver = smtpserver
2051         self.smtpservertimeout = smtpservertimeout
2052         self.smtpserverdebuglevel = smtpserverdebuglevel
2053         self.security = smtpencryption
2054         self.username = smtpuser
2055         self.password = smtppass
2056         self.smtpcacerts = smtpcacerts
2057         try:
2058             def call(klass, server, timeout):
2059                 try:
2060                     return klass(server, timeout=timeout)
2061                 except TypeError:
2062                     # Old Python versions do not have timeout= argument.
2063                     return klass(server)
2064             if self.security == 'none':
2065                 self.smtp = call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)
2066             elif self.security == 'ssl':
2067                 if self.smtpcacerts:
2068                     raise smtplib.SMTPException(
2069                         "Checking certificate is not supported for ssl, prefer starttls"
2070                         )
2071                 self.smtp = call(smtplib.SMTP_SSL, self.smtpserver, timeout=self.smtpservertimeout)
2072             elif self.security == 'tls':
2073                 if 'ssl' not in sys.modules:
2074                     self.environment.get_logger().error(
2075                         '*** Your Python version does not have the ssl library installed\n'
2076                         '*** smtpEncryption=tls is not available.\n'
2077                         '*** Either upgrade Python to 2.6 or later\n'
2078                         '    or use git_multimail.py version 1.2.\n')
2079                 if ':' not in self.smtpserver:
2080                     self.smtpserver += ':587'  # default port for TLS
2081                 self.smtp = call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)
2082                 # start: ehlo + starttls
2083                 # equivalent to
2084                 #     self.smtp.ehlo()
2085                 #     self.smtp.starttls()
2086                 # with acces to the ssl layer
2087                 self.smtp.ehlo()
2088                 if not self.smtp.has_extn("starttls"):
2089                     raise smtplib.SMTPException("STARTTLS extension not supported by server")
2090                 resp, reply = self.smtp.docmd("STARTTLS")
2091                 if resp != 220:
2092                     raise smtplib.SMTPException("Wrong answer to the STARTTLS command")
2093                 if self.smtpcacerts:
2094                     self.smtp.sock = ssl.wrap_socket(
2095                         self.smtp.sock,
2096                         ca_certs=self.smtpcacerts,
2097                         cert_reqs=ssl.CERT_REQUIRED
2098                         )
2099                 else:
2100                     self.smtp.sock = ssl.wrap_socket(
2101                         self.smtp.sock,
2102                         cert_reqs=ssl.CERT_NONE
2103                         )
2104                     self.environment.get_logger().error(
2105                         '*** Warning, the server certificat is not verified (smtp) ***\n'
2106                         '***          set the option smtpCACerts                   ***\n'
2107                         )
2108                 if not hasattr(self.smtp.sock, "read"):
2109                     # using httplib.FakeSocket with Python 2.5.x or earlier
2110                     self.smtp.sock.read = self.smtp.sock.recv
2111                 self.smtp.file = smtplib.SSLFakeFile(self.smtp.sock)
2112                 self.smtp.helo_resp = None
2113                 self.smtp.ehlo_resp = None
2114                 self.smtp.esmtp_features = {}
2115                 self.smtp.does_esmtp = 0
2116                 # end:   ehlo + starttls
2117                 self.smtp.ehlo()
2118             else:
2119                 sys.stdout.write('*** Error: Control reached an invalid option. ***')
2120                 sys.exit(1)
2121             if self.smtpserverdebuglevel > 0:
2122                 sys.stdout.write(
2123                     "*** Setting debug on for SMTP server connection (%s) ***\n"
2124                     % self.smtpserverdebuglevel)
2125                 self.smtp.set_debuglevel(self.smtpserverdebuglevel)
2126         except Exception:
2127             self.environment.get_logger().error(
2128                 '*** Error establishing SMTP connection to %s ***\n'
2129                 '*** %s\n'
2130                 % (self.smtpserver, sys.exc_info()[1]))
2131             sys.exit(1)
2132
2133     def __del__(self):
2134         if hasattr(self, 'smtp'):
2135             self.smtp.quit()
2136             del self.smtp
2137
2138     def send(self, lines, to_addrs):
2139         try:
2140             if self.username or self.password:
2141                 self.smtp.login(self.username, self.password)
2142             msg = ''.join(lines)
2143             # turn comma-separated list into Python list if needed.
2144             if is_string(to_addrs):
2145                 to_addrs = [email for (name, email) in getaddresses([to_addrs])]
2146             self.smtp.sendmail(self.envelopesender, to_addrs, msg)
2147         except smtplib.SMTPResponseException:
2148             err = sys.exc_info()[1]
2149             self.environment.get_logger().error(
2150                 '*** Error sending email ***\n'
2151                 '*** Error %d: %s\n'
2152                 % (err.smtp_code, bytes_to_str(err.smtp_error)))
2153             try:
2154                 smtp = self.smtp
2155                 # delete the field before quit() so that in case of
2156                 # error, self.smtp is deleted anyway.
2157                 del self.smtp
2158                 smtp.quit()
2159             except:
2160                 self.environment.get_logger().error(
2161                     '*** Error closing the SMTP connection ***\n'
2162                     '*** Exiting anyway ... ***\n'
2163                     '*** %s\n' % sys.exc_info()[1])
2164             sys.exit(1)
2165
2166
2167 class OutputMailer(Mailer):
2168     """Write emails to an output stream, bracketed by lines of '=' characters.
2169
2170     This is intended for debugging purposes."""
2171
2172     SEPARATOR = '=' * 75 + '\n'
2173
2174     def __init__(self, f):
2175         self.f = f
2176
2177     def send(self, lines, to_addrs):
2178         write_str(self.f, self.SEPARATOR)
2179         for line in lines:
2180             write_str(self.f, line)
2181         write_str(self.f, self.SEPARATOR)
2182
2183
2184 def get_git_dir():
2185     """Determine GIT_DIR.
2186
2187     Determine GIT_DIR either from the GIT_DIR environment variable or
2188     from the working directory, using Git's usual rules."""
2189
2190     try:
2191         return read_git_output(['rev-parse', '--git-dir'])
2192     except CommandError:
2193         sys.stderr.write('fatal: git_multimail: not in a git directory\n')
2194         sys.exit(1)
2195
2196
2197 class Environment(object):
2198     """Describes the environment in which the push is occurring.
2199
2200     An Environment object encapsulates information about the local
2201     environment.  For example, it knows how to determine:
2202
2203     * the name of the repository to which the push occurred
2204
2205     * what user did the push
2206
2207     * what users want to be informed about various types of changes.
2208
2209     An Environment object is expected to have the following methods:
2210
2211         get_repo_shortname()
2212
2213             Return a short name for the repository, for display
2214             purposes.
2215
2216         get_repo_path()
2217
2218             Return the absolute path to the Git repository.
2219
2220         get_emailprefix()
2221
2222             Return a string that will be prefixed to every email's
2223             subject.
2224
2225         get_pusher()
2226
2227             Return the username of the person who pushed the changes.
2228             This value is used in the email body to indicate who
2229             pushed the change.
2230
2231         get_pusher_email() (may return None)
2232
2233             Return the email address of the person who pushed the
2234             changes.  The value should be a single RFC 2822 email
2235             address as a string; e.g., "Joe User <user@example.com>"
2236             if available, otherwise "user@example.com".  If set, the
2237             value is used as the Reply-To address for refchange
2238             emails.  If it is impossible to determine the pusher's
2239             email, this attribute should be set to None (in which case
2240             no Reply-To header will be output).
2241
2242         get_sender()
2243
2244             Return the address to be used as the 'From' email address
2245             in the email envelope.
2246
2247         get_fromaddr(change=None)
2248
2249             Return the 'From' email address used in the email 'From:'
2250             headers.  If the change is known when this function is
2251             called, it is passed in as the 'change' parameter.  (May
2252             be a full RFC 2822 email address like 'Joe User
2253             <user@example.com>'.)
2254
2255         get_administrator()
2256
2257             Return the name and/or email of the repository
2258             administrator.  This value is used in the footer as the
2259             person to whom requests to be removed from the
2260             notification list should be sent.  Ideally, it should
2261             include a valid email address.
2262
2263         get_reply_to_refchange()
2264         get_reply_to_commit()
2265
2266             Return the address to use in the email "Reply-To" header,
2267             as a string.  These can be an RFC 2822 email address, or
2268             None to omit the "Reply-To" header.
2269             get_reply_to_refchange() is used for refchange emails;
2270             get_reply_to_commit() is used for individual commit
2271             emails.
2272
2273         get_ref_filter_regex()
2274
2275             Return a tuple -- a compiled regex, and a boolean indicating
2276             whether the regex picks refs to include (if False, the regex
2277             matches on refs to exclude).
2278
2279         get_default_ref_ignore_regex()
2280
2281             Return a regex that should be ignored for both what emails
2282             to send and when computing what commits are considered new
2283             to the repository.  Default is "^refs/notes/".
2284
2285         get_max_subject_length()
2286
2287             Return an int giving the maximal length for the subject
2288             (git log --oneline).
2289
2290     They should also define the following attributes:
2291
2292         announce_show_shortlog (bool)
2293
2294             True iff announce emails should include a shortlog.
2295
2296         commit_email_format (string)
2297
2298             If "html", generate commit emails in HTML instead of plain text
2299             used by default.
2300
2301         html_in_intro (bool)
2302         html_in_footer (bool)
2303
2304             When generating HTML emails, the introduction (respectively,
2305             the footer) will be HTML-escaped iff html_in_intro (respectively,
2306             the footer) is true. When false, only the values used to expand
2307             the template are escaped.
2308
2309         refchange_showgraph (bool)
2310
2311             True iff refchanges emails should include a detailed graph.
2312
2313         refchange_showlog (bool)
2314
2315             True iff refchanges emails should include a detailed log.
2316
2317         diffopts (list of strings)
2318
2319             The options that should be passed to 'git diff' for the
2320             summary email.  The value should be a list of strings
2321             representing words to be passed to the command.
2322
2323         graphopts (list of strings)
2324
2325             Analogous to diffopts, but contains options passed to
2326             'git log --graph' when generating the detailed graph for
2327             a set of commits (see refchange_showgraph)
2328
2329         logopts (list of strings)
2330
2331             Analogous to diffopts, but contains options passed to
2332             'git log' when generating the detailed log for a set of
2333             commits (see refchange_showlog)
2334
2335         commitlogopts (list of strings)
2336
2337             The options that should be passed to 'git log' for each
2338             commit mail.  The value should be a list of strings
2339             representing words to be passed to the command.
2340
2341         date_substitute (string)
2342
2343             String to be used in substitution for 'Date:' at start of
2344             line in the output of 'git log'.
2345
2346         quiet (bool)
2347             On success do not write to stderr
2348
2349         stdout (bool)
2350             Write email to stdout rather than emailing. Useful for debugging
2351
2352         combine_when_single_commit (bool)
2353
2354             True if a combined email should be produced when a single
2355             new commit is pushed to a branch, False otherwise.
2356
2357         from_refchange, from_commit (strings)
2358
2359             Addresses to use for the From: field for refchange emails
2360             and commit emails respectively.  Set from
2361             multimailhook.fromRefchange and multimailhook.fromCommit
2362             by ConfigEnvironmentMixin.
2363
2364         log_file, error_log_file, debug_log_file (string)
2365
2366             Name of a file to which logs should be sent.
2367
2368         verbose (int)
2369
2370             How verbose the system should be.
2371             - 0 (default): show info, errors, ...
2372             - 1 : show basic debug info
2373     """
2374
2375     REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$')
2376
2377     def __init__(self, osenv=None):
2378         self.osenv = osenv or os.environ
2379         self.announce_show_shortlog = False
2380         self.commit_email_format = "text"
2381         self.html_in_intro = False
2382         self.html_in_footer = False
2383         self.commitBrowseURL = None
2384         self.maxcommitemails = 500
2385         self.diffopts = ['--stat', '--summary', '--find-copies-harder']
2386         self.graphopts = ['--oneline', '--decorate']
2387         self.logopts = []
2388         self.refchange_showgraph = False
2389         self.refchange_showlog = False
2390         self.commitlogopts = ['-C', '--stat', '-p', '--cc']
2391         self.date_substitute = 'AuthorDate: '
2392         self.quiet = False
2393         self.stdout = False
2394         self.combine_when_single_commit = True
2395         self.logger = None
2396
2397         self.COMPUTED_KEYS = [
2398             'administrator',
2399             'charset',
2400             'emailprefix',
2401             'pusher',
2402             'pusher_email',
2403             'repo_path',
2404             'repo_shortname',
2405             'sender',
2406             ]
2407
2408         self._values = None
2409
2410     def get_logger(self):
2411         """Get (possibly creates) the logger associated to this environment."""
2412         if self.logger is None:
2413             self.logger = Logger(self)
2414         return self.logger
2415
2416     def get_repo_shortname(self):
2417         """Use the last part of the repo path, with ".git" stripped off if present."""
2418
2419         basename = os.path.basename(os.path.abspath(self.get_repo_path()))
2420         m = self.REPO_NAME_RE.match(basename)
2421         if m:
2422             return m.group('name')
2423         else:
2424             return basename
2425
2426     def get_pusher(self):
2427         raise NotImplementedError()
2428
2429     def get_pusher_email(self):
2430         return None
2431
2432     def get_fromaddr(self, change=None):
2433         config = Config('user')
2434         fromname = config.get('name', default='')
2435         fromemail = config.get('email', default='')
2436         if fromemail:
2437             return formataddr([fromname, fromemail])
2438         return self.get_sender()
2439
2440     def get_administrator(self):
2441         return 'the administrator of this repository'
2442
2443     def get_emailprefix(self):
2444         return ''
2445
2446     def get_repo_path(self):
2447         if read_git_output(['rev-parse', '--is-bare-repository']) == 'true':
2448             path = get_git_dir()
2449         else:
2450             path = read_git_output(['rev-parse', '--show-toplevel'])
2451         return os.path.abspath(path)
2452
2453     def get_charset(self):
2454         return CHARSET
2455
2456     def get_values(self):
2457         """Return a dictionary {keyword: expansion} for this Environment.
2458
2459         This method is called by Change._compute_values().  The keys
2460         in the returned dictionary are available to be used in any of
2461         the templates.  The dictionary is created by calling
2462         self.get_NAME() for each of the attributes named in
2463         COMPUTED_KEYS and recording those that do not return None.
2464         The return value is always a new dictionary."""
2465
2466         if self._values is None:
2467             values = {'': ''}  # %()s expands to the empty string.
2468
2469             for key in self.COMPUTED_KEYS:
2470                 value = getattr(self, 'get_%s' % (key,))()
2471                 if value is not None:
2472                     values[key] = value
2473
2474             self._values = values
2475
2476         return self._values.copy()
2477
2478     def get_refchange_recipients(self, refchange):
2479         """Return the recipients for notifications about refchange.
2480
2481         Return the list of email addresses to which notifications
2482         about the specified ReferenceChange should be sent."""
2483
2484         raise NotImplementedError()
2485
2486     def get_announce_recipients(self, annotated_tag_change):
2487         """Return the recipients for notifications about annotated_tag_change.
2488
2489         Return the list of email addresses to which notifications
2490         about the specified AnnotatedTagChange should be sent."""
2491
2492         raise NotImplementedError()
2493
2494     def get_reply_to_refchange(self, refchange):
2495         return self.get_pusher_email()
2496
2497     def get_revision_recipients(self, revision):
2498         """Return the recipients for messages about revision.
2499
2500         Return the list of email addresses to which notifications
2501         about the specified Revision should be sent.  This method
2502         could be overridden, for example, to take into account the
2503         contents of the revision when deciding whom to notify about
2504         it.  For example, there could be a scheme for users to express
2505         interest in particular files or subdirectories, and only
2506         receive notification emails for revisions that affecting those
2507         files."""
2508
2509         raise NotImplementedError()
2510
2511     def get_reply_to_commit(self, revision):
2512         return revision.author
2513
2514     def get_default_ref_ignore_regex(self):
2515         # The commit messages of git notes are essentially meaningless
2516         # and "filenames" in git notes commits are an implementational
2517         # detail that might surprise users at first.  As such, we
2518         # would need a completely different method for handling emails
2519         # of git notes in order for them to be of benefit for users,
2520         # which we simply do not have right now.
2521         return "^refs/notes/"
2522
2523     def get_max_subject_length(self):
2524         """Return the maximal subject line (git log --oneline) length.
2525         Longer subject lines will be truncated."""
2526         raise NotImplementedError()
2527
2528     def filter_body(self, lines):
2529         """Filter the lines intended for an email body.
2530
2531         lines is an iterable over the lines that would go into the
2532         email body.  Filter it (e.g., limit the number of lines, the
2533         line length, character set, etc.), returning another iterable.
2534         See FilterLinesEnvironmentMixin and MaxlinesEnvironmentMixin
2535         for classes implementing this functionality."""
2536
2537         return lines
2538
2539     def log_msg(self, msg):
2540         """Write the string msg on a log file or on stderr.
2541
2542         Sends the text to stderr by default, override to change the behavior."""
2543         self.get_logger().info(msg)
2544
2545     def log_warning(self, msg):
2546         """Write the string msg on a log file or on stderr.
2547
2548         Sends the text to stderr by default, override to change the behavior."""
2549         self.get_logger().warning(msg)
2550
2551     def log_error(self, msg):
2552         """Write the string msg on a log file or on stderr.
2553
2554         Sends the text to stderr by default, override to change the behavior."""
2555         self.get_logger().error(msg)
2556
2557     def check(self):
2558         pass
2559
2560
2561 class ConfigEnvironmentMixin(Environment):
2562     """A mixin that sets self.config to its constructor's config argument.
2563
2564     This class's constructor consumes the "config" argument.
2565
2566     Mixins that need to inspect the config should inherit from this
2567     class (1) to make sure that "config" is still in the constructor
2568     arguments with its own constructor runs and/or (2) to be sure that
2569     self.config is set after construction."""
2570
2571     def __init__(self, config, **kw):
2572         super(ConfigEnvironmentMixin, self).__init__(**kw)
2573         self.config = config
2574
2575
2576 class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):
2577     """An Environment that reads most of its information from "git config"."""
2578
2579     @staticmethod
2580     def forbid_field_values(name, value, forbidden):
2581         for forbidden_val in forbidden:
2582             if value is not None and value.lower() == forbidden:
2583                 raise ConfigurationException(
2584                     '"%s" is not an allowed setting for %s' % (value, name)
2585                     )
2586
2587     def __init__(self, config, **kw):
2588         super(ConfigOptionsEnvironmentMixin, self).__init__(
2589             config=config, **kw
2590             )
2591
2592         for var, cfg in (
2593                 ('announce_show_shortlog', 'announceshortlog'),
2594                 ('refchange_showgraph', 'refchangeShowGraph'),
2595                 ('refchange_showlog', 'refchangeshowlog'),
2596                 ('quiet', 'quiet'),
2597                 ('stdout', 'stdout'),
2598                 ):
2599             val = config.get_bool(cfg)
2600             if val is not None:
2601                 setattr(self, var, val)
2602
2603         commit_email_format = config.get('commitEmailFormat')
2604         if commit_email_format is not None:
2605             if commit_email_format != "html" and commit_email_format != "text":
2606                 self.log_warning(
2607                     '*** Unknown value for multimailhook.commitEmailFormat: %s\n' %
2608                     commit_email_format +
2609                     '*** Expected either "text" or "html".  Ignoring.\n'
2610                     )
2611             else:
2612                 self.commit_email_format = commit_email_format
2613
2614         html_in_intro = config.get_bool('htmlInIntro')
2615         if html_in_intro is not None:
2616             self.html_in_intro = html_in_intro
2617
2618         html_in_footer = config.get_bool('htmlInFooter')
2619         if html_in_footer is not None:
2620             self.html_in_footer = html_in_footer
2621
2622         self.commitBrowseURL = config.get('commitBrowseURL')
2623
2624         maxcommitemails = config.get('maxcommitemails')
2625         if maxcommitemails is not None:
2626             try:
2627                 self.maxcommitemails = int(maxcommitemails)
2628             except ValueError:
2629                 self.log_warning(
2630                     '*** Malformed value for multimailhook.maxCommitEmails: %s\n'
2631                     % maxcommitemails +
2632                     '*** Expected a number.  Ignoring.\n'
2633                     )
2634
2635         diffopts = config.get('diffopts')
2636         if diffopts is not None:
2637             self.diffopts = shlex.split(diffopts)
2638
2639         graphopts = config.get('graphOpts')
2640         if graphopts is not None:
2641             self.graphopts = shlex.split(graphopts)
2642
2643         logopts = config.get('logopts')
2644         if logopts is not None:
2645             self.logopts = shlex.split(logopts)
2646
2647         commitlogopts = config.get('commitlogopts')
2648         if commitlogopts is not None:
2649             self.commitlogopts = shlex.split(commitlogopts)
2650
2651         date_substitute = config.get('dateSubstitute')
2652         if date_substitute == 'none':
2653             self.date_substitute = None
2654         elif date_substitute is not None:
2655             self.date_substitute = date_substitute
2656
2657         reply_to = config.get('replyTo')
2658         self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to)
2659         self.forbid_field_values('replyToRefchange',
2660                                  self.__reply_to_refchange,
2661                                  ['author'])
2662         self.__reply_to_commit = config.get('replyToCommit', default=reply_to)
2663
2664         self.from_refchange = config.get('fromRefchange')
2665         self.forbid_field_values('fromRefchange',
2666                                  self.from_refchange,
2667                                  ['author', 'none'])
2668         self.from_commit = config.get('fromCommit')
2669         self.forbid_field_values('fromCommit',
2670                                  self.from_commit,
2671                                  ['none'])
2672
2673         combine = config.get_bool('combineWhenSingleCommit')
2674         if combine is not None:
2675             self.combine_when_single_commit = combine
2676
2677         self.log_file = config.get('logFile', default=None)
2678         self.error_log_file = config.get('errorLogFile', default=None)
2679         self.debug_log_file = config.get('debugLogFile', default=None)
2680         if config.get_bool('Verbose', default=False):
2681             self.verbose = 1
2682         else:
2683             self.verbose = 0
2684
2685     def get_administrator(self):
2686         return (
2687             self.config.get('administrator') or
2688             self.get_sender() or
2689             super(ConfigOptionsEnvironmentMixin, self).get_administrator()
2690             )
2691
2692     def get_repo_shortname(self):
2693         return (
2694             self.config.get('reponame') or
2695             super(ConfigOptionsEnvironmentMixin, self).get_repo_shortname()
2696             )
2697
2698     def get_emailprefix(self):
2699         emailprefix = self.config.get('emailprefix')
2700         if emailprefix is not None:
2701             emailprefix = emailprefix.strip()
2702             if emailprefix:
2703                 emailprefix += ' '
2704         else:
2705             emailprefix = '[%(repo_shortname)s] '
2706         short_name = self.get_repo_shortname()
2707         try:
2708             return emailprefix % {'repo_shortname': short_name}
2709         except:
2710             self.get_logger().error(
2711                 '*** Invalid multimailhook.emailPrefix: %s\n' % emailprefix +
2712                 '*** %s\n' % sys.exc_info()[1] +
2713                 "*** Only the '%(repo_shortname)s' placeholder is allowed\n"
2714                 )
2715             raise ConfigurationException(
2716                 '"%s" is not an allowed setting for emailPrefix' % emailprefix
2717                 )
2718
2719     def get_sender(self):
2720         return self.config.get('envelopesender')
2721
2722     def process_addr(self, addr, change):
2723         if addr.lower() == 'author':
2724             if hasattr(change, 'author'):
2725                 return change.author
2726             else:
2727                 return None
2728         elif addr.lower() == 'pusher':
2729             return self.get_pusher_email()
2730         elif addr.lower() == 'none':
2731             return None
2732         else:
2733             return addr
2734
2735     def get_fromaddr(self, change=None):
2736         fromaddr = self.config.get('from')
2737         if change:
2738             specific_fromaddr = change.get_specific_fromaddr()
2739             if specific_fromaddr:
2740                 fromaddr = specific_fromaddr
2741         if fromaddr:
2742             fromaddr = self.process_addr(fromaddr, change)
2743         if fromaddr:
2744             return fromaddr
2745         return super(ConfigOptionsEnvironmentMixin, self).get_fromaddr(change)
2746
2747     def get_reply_to_refchange(self, refchange):
2748         if self.__reply_to_refchange is None:
2749             return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_refchange(refchange)
2750         else:
2751             return self.process_addr(self.__reply_to_refchange, refchange)
2752
2753     def get_reply_to_commit(self, revision):
2754         if self.__reply_to_commit is None:
2755             return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_commit(revision)
2756         else:
2757             return self.process_addr(self.__reply_to_commit, revision)
2758
2759     def get_scancommitforcc(self):
2760         return self.config.get('scancommitforcc')
2761
2762
2763 class FilterLinesEnvironmentMixin(Environment):
2764     """Handle encoding and maximum line length of body lines.
2765
2766         email_max_line_length (int or None)
2767
2768             The maximum length of any single line in the email body.
2769             Longer lines are truncated at that length with ' [...]'
2770             appended.
2771
2772         strict_utf8 (bool)
2773
2774             If this field is set to True, then the email body text is
2775             expected to be UTF-8.  Any invalid characters are
2776             converted to U+FFFD, the Unicode replacement character
2777             (encoded as UTF-8, of course).
2778
2779     """
2780
2781     def __init__(self, strict_utf8=True,
2782                  email_max_line_length=500, max_subject_length=500,
2783                  **kw):
2784         super(FilterLinesEnvironmentMixin, self).__init__(**kw)
2785         self.__strict_utf8 = strict_utf8
2786         self.__email_max_line_length = email_max_line_length
2787         self.__max_subject_length = max_subject_length
2788
2789     def filter_body(self, lines):
2790         lines = super(FilterLinesEnvironmentMixin, self).filter_body(lines)
2791         if self.__strict_utf8:
2792             if not PYTHON3:
2793                 lines = (line.decode(ENCODING, 'replace') for line in lines)
2794             # Limit the line length in Unicode-space to avoid
2795             # splitting characters:
2796             if self.__email_max_line_length > 0:
2797                 lines = limit_linelength(lines, self.__email_max_line_length)
2798             if not PYTHON3:
2799                 lines = (line.encode(ENCODING, 'replace') for line in lines)
2800         elif self.__email_max_line_length:
2801             lines = limit_linelength(lines, self.__email_max_line_length)
2802
2803         return lines
2804
2805     def get_max_subject_length(self):
2806         return self.__max_subject_length
2807
2808
2809 class ConfigFilterLinesEnvironmentMixin(
2810         ConfigEnvironmentMixin,
2811         FilterLinesEnvironmentMixin,
2812         ):
2813     """Handle encoding and maximum line length based on config."""
2814
2815     def __init__(self, config, **kw):
2816         strict_utf8 = config.get_bool('emailstrictutf8', default=None)
2817         if strict_utf8 is not None:
2818             kw['strict_utf8'] = strict_utf8
2819
2820         email_max_line_length = config.get('emailmaxlinelength')
2821         if email_max_line_length is not None:
2822             kw['email_max_line_length'] = int(email_max_line_length)
2823
2824         max_subject_length = config.get('subjectMaxLength', default=email_max_line_length)
2825         if max_subject_length is not None:
2826             kw['max_subject_length'] = int(max_subject_length)
2827
2828         super(ConfigFilterLinesEnvironmentMixin, self).__init__(
2829             config=config, **kw
2830             )
2831
2832
2833 class MaxlinesEnvironmentMixin(Environment):
2834     """Limit the email body to a specified number of lines."""
2835
2836     def __init__(self, emailmaxlines, **kw):
2837         super(MaxlinesEnvironmentMixin, self).__init__(**kw)
2838         self.__emailmaxlines = emailmaxlines
2839
2840     def filter_body(self, lines):
2841         lines = super(MaxlinesEnvironmentMixin, self).filter_body(lines)
2842         if self.__emailmaxlines > 0:
2843             lines = limit_lines(lines, self.__emailmaxlines)
2844         return lines
2845
2846
2847 class ConfigMaxlinesEnvironmentMixin(
2848         ConfigEnvironmentMixin,
2849         MaxlinesEnvironmentMixin,
2850         ):
2851     """Limit the email body to the number of lines specified in config."""
2852
2853     def __init__(self, config, **kw):
2854         emailmaxlines = int(config.get('emailmaxlines', default='0'))
2855         super(ConfigMaxlinesEnvironmentMixin, self).__init__(
2856             config=config,
2857             emailmaxlines=emailmaxlines,
2858             **kw
2859             )
2860
2861
2862 class FQDNEnvironmentMixin(Environment):
2863     """A mixin that sets the host's FQDN to its constructor argument."""
2864
2865     def __init__(self, fqdn, **kw):
2866         super(FQDNEnvironmentMixin, self).__init__(**kw)
2867         self.COMPUTED_KEYS += ['fqdn']
2868         self.__fqdn = fqdn
2869
2870     def get_fqdn(self):
2871         """Return the fully-qualified domain name for this host.
2872
2873         Return None if it is unavailable or unwanted."""
2874
2875         return self.__fqdn
2876
2877
2878 class ConfigFQDNEnvironmentMixin(
2879         ConfigEnvironmentMixin,
2880         FQDNEnvironmentMixin,
2881         ):
2882     """Read the FQDN from the config."""
2883
2884     def __init__(self, config, **kw):
2885         fqdn = config.get('fqdn')
2886         super(ConfigFQDNEnvironmentMixin, self).__init__(
2887             config=config,
2888             fqdn=fqdn,
2889             **kw
2890             )
2891
2892
2893 class ComputeFQDNEnvironmentMixin(FQDNEnvironmentMixin):
2894     """Get the FQDN by calling socket.getfqdn()."""
2895
2896     def __init__(self, **kw):
2897         super(ComputeFQDNEnvironmentMixin, self).__init__(
2898             fqdn=socket.getfqdn(),
2899             **kw
2900             )
2901
2902
2903 class PusherDomainEnvironmentMixin(ConfigEnvironmentMixin):
2904     """Deduce pusher_email from pusher by appending an emaildomain."""
2905
2906     def __init__(self, **kw):
2907         super(PusherDomainEnvironmentMixin, self).__init__(**kw)
2908         self.__emaildomain = self.config.get('emaildomain')
2909
2910     def get_pusher_email(self):
2911         if self.__emaildomain:
2912             # Derive the pusher's full email address in the default way:
2913             return '%s@%s' % (self.get_pusher(), self.__emaildomain)
2914         else:
2915             return super(PusherDomainEnvironmentMixin, self).get_pusher_email()
2916
2917
2918 class StaticRecipientsEnvironmentMixin(Environment):
2919     """Set recipients statically based on constructor parameters."""
2920
2921     def __init__(
2922             self,
2923             refchange_recipients, announce_recipients, revision_recipients, scancommitforcc,
2924             **kw
2925             ):
2926         super(StaticRecipientsEnvironmentMixin, self).__init__(**kw)
2927
2928         # The recipients for various types of notification emails, as
2929         # RFC 2822 email addresses separated by commas (or the empty
2930         # string if no recipients are configured).  Although there is
2931         # a mechanism to choose the recipient lists based on on the
2932         # actual *contents* of the change being reported, we only
2933         # choose based on the *type* of the change.  Therefore we can
2934         # compute them once and for all:
2935         self.__refchange_recipients = refchange_recipients
2936         self.__announce_recipients = announce_recipients
2937         self.__revision_recipients = revision_recipients
2938
2939     def check(self):
2940         if not (self.get_refchange_recipients(None) or
2941                 self.get_announce_recipients(None) or
2942                 self.get_revision_recipients(None) or
2943                 self.get_scancommitforcc()):
2944             raise ConfigurationException('No email recipients configured!')
2945         super(StaticRecipientsEnvironmentMixin, self).check()
2946
2947     def get_refchange_recipients(self, refchange):
2948         if self.__refchange_recipients is None:
2949             return super(StaticRecipientsEnvironmentMixin,
2950                          self).get_refchange_recipients(refchange)
2951         return self.__refchange_recipients
2952
2953     def get_announce_recipients(self, annotated_tag_change):
2954         if self.__announce_recipients is None:
2955             return super(StaticRecipientsEnvironmentMixin,
2956                          self).get_refchange_recipients(annotated_tag_change)
2957         return self.__announce_recipients
2958
2959     def get_revision_recipients(self, revision):
2960         if self.__revision_recipients is None:
2961             return super(StaticRecipientsEnvironmentMixin,
2962                          self).get_refchange_recipients(revision)
2963         return self.__revision_recipients
2964
2965
2966 class CLIRecipientsEnvironmentMixin(Environment):
2967     """Mixin storing recipients information coming from the
2968     command-line."""
2969
2970     def __init__(self, cli_recipients=None, **kw):
2971         super(CLIRecipientsEnvironmentMixin, self).__init__(**kw)
2972         self.__cli_recipients = cli_recipients
2973
2974     def get_refchange_recipients(self, refchange):
2975         if self.__cli_recipients is None:
2976             return super(CLIRecipientsEnvironmentMixin,
2977                          self).get_refchange_recipients(refchange)
2978         return self.__cli_recipients
2979
2980     def get_announce_recipients(self, annotated_tag_change):
2981         if self.__cli_recipients is None:
2982             return super(CLIRecipientsEnvironmentMixin,
2983                          self).get_announce_recipients(annotated_tag_change)
2984         return self.__cli_recipients
2985
2986     def get_revision_recipients(self, revision):
2987         if self.__cli_recipients is None:
2988             return super(CLIRecipientsEnvironmentMixin,
2989                          self).get_revision_recipients(revision)
2990         return self.__cli_recipients
2991
2992
2993 class ConfigRecipientsEnvironmentMixin(
2994         ConfigEnvironmentMixin,
2995         StaticRecipientsEnvironmentMixin
2996         ):
2997     """Determine recipients statically based on config."""
2998
2999     def __init__(self, config, **kw):
3000         super(ConfigRecipientsEnvironmentMixin, self).__init__(
3001             config=config,
3002             refchange_recipients=self._get_recipients(
3003                 config, 'refchangelist', 'mailinglist',
3004                 ),
3005             announce_recipients=self._get_recipients(
3006                 config, 'announcelist', 'refchangelist', 'mailinglist',
3007                 ),
3008             revision_recipients=self._get_recipients(
3009                 config, 'commitlist', 'mailinglist',
3010                 ),
3011             scancommitforcc=config.get('scancommitforcc'),
3012             **kw
3013             )
3014
3015     def _get_recipients(self, config, *names):
3016         """Return the recipients for a particular type of message.
3017
3018         Return the list of email addresses to which a particular type
3019         of notification email should be sent, by looking at the config
3020         value for "multimailhook.$name" for each of names.  Use the
3021         value from the first name that is configured.  The return
3022         value is a (possibly empty) string containing RFC 2822 email
3023         addresses separated by commas.  If no configuration could be
3024         found, raise a ConfigurationException."""
3025
3026         for name in names:
3027             lines = config.get_all(name)
3028             if lines is not None:
3029                 lines = [line.strip() for line in lines]
3030                 # Single "none" is a special value equivalen to empty string.
3031                 if lines == ['none']:
3032                     lines = ['']
3033                 return ', '.join(lines)
3034         else:
3035             return ''
3036
3037
3038 class StaticRefFilterEnvironmentMixin(Environment):
3039     """Set branch filter statically based on constructor parameters."""
3040
3041     def __init__(self, ref_filter_incl_regex, ref_filter_excl_regex,
3042                  ref_filter_do_send_regex, ref_filter_dont_send_regex,
3043                  **kw):
3044         super(StaticRefFilterEnvironmentMixin, self).__init__(**kw)
3045
3046         if ref_filter_incl_regex and ref_filter_excl_regex:
3047             raise ConfigurationException(
3048                 "Cannot specify both a ref inclusion and exclusion regex.")
3049         self.__is_inclusion_filter = bool(ref_filter_incl_regex)
3050         default_exclude = self.get_default_ref_ignore_regex()
3051         if ref_filter_incl_regex:
3052             ref_filter_regex = ref_filter_incl_regex
3053         elif ref_filter_excl_regex:
3054             ref_filter_regex = ref_filter_excl_regex + '|' + default_exclude
3055         else:
3056             ref_filter_regex = default_exclude
3057         try:
3058             self.__compiled_regex = re.compile(ref_filter_regex)
3059         except Exception:
3060             raise ConfigurationException(
3061                 'Invalid Ref Filter Regex "%s": %s' % (ref_filter_regex, sys.exc_info()[1]))
3062
3063         if ref_filter_do_send_regex and ref_filter_dont_send_regex:
3064             raise ConfigurationException(
3065                 "Cannot specify both a ref doSend and dontSend regex.")
3066         self.__is_do_send_filter = bool(ref_filter_do_send_regex)
3067         if ref_filter_do_send_regex:
3068             ref_filter_send_regex = ref_filter_do_send_regex
3069         elif ref_filter_dont_send_regex:
3070             ref_filter_send_regex = ref_filter_dont_send_regex
3071         else:
3072             ref_filter_send_regex = '.*'
3073             self.__is_do_send_filter = True
3074         try:
3075             self.__send_compiled_regex = re.compile(ref_filter_send_regex)
3076         except Exception:
3077             raise ConfigurationException(
3078                 'Invalid Ref Filter Regex "%s": %s' %
3079                 (ref_filter_send_regex, sys.exc_info()[1]))
3080
3081     def get_ref_filter_regex(self, send_filter=False):
3082         if send_filter:
3083             return self.__send_compiled_regex, self.__is_do_send_filter
3084         else:
3085             return self.__compiled_regex, self.__is_inclusion_filter
3086
3087
3088 class ConfigRefFilterEnvironmentMixin(
3089         ConfigEnvironmentMixin,
3090         StaticRefFilterEnvironmentMixin
3091         ):
3092     """Determine branch filtering statically based on config."""
3093
3094     def _get_regex(self, config, key):
3095         """Get a list of whitespace-separated regex. The refFilter* config
3096         variables are multivalued (hence the use of get_all), and we
3097         allow each entry to be a whitespace-separated list (hence the
3098         split on each line). The whole thing is glued into a single regex."""
3099         values = config.get_all(key)
3100         if values is None:
3101             return values
3102         items = []
3103         for line in values:
3104             for i in line.split():
3105                 items.append(i)
3106         if items == []:
3107             return None
3108         return '|'.join(items)
3109
3110     def __init__(self, config, **kw):
3111         super(ConfigRefFilterEnvironmentMixin, self).__init__(
3112             config=config,
3113             ref_filter_incl_regex=self._get_regex(config, 'refFilterInclusionRegex'),
3114             ref_filter_excl_regex=self._get_regex(config, 'refFilterExclusionRegex'),
3115             ref_filter_do_send_regex=self._get_regex(config, 'refFilterDoSendRegex'),
3116             ref_filter_dont_send_regex=self._get_regex(config, 'refFilterDontSendRegex'),
3117             **kw
3118             )
3119
3120
3121 class ProjectdescEnvironmentMixin(Environment):
3122     """Make a "projectdesc" value available for templates.
3123
3124     By default, it is set to the first line of $GIT_DIR/description
3125     (if that file is present and appears to be set meaningfully)."""
3126
3127     def __init__(self, **kw):
3128         super(ProjectdescEnvironmentMixin, self).__init__(**kw)
3129         self.COMPUTED_KEYS += ['projectdesc']
3130
3131     def get_projectdesc(self):
3132         """Return a one-line descripition of the project."""
3133
3134         git_dir = get_git_dir()
3135         try:
3136             projectdesc = open(os.path.join(git_dir, 'description')).readline().strip()
3137             if projectdesc and not projectdesc.startswith('Unnamed repository'):
3138                 return projectdesc
3139         except IOError:
3140             pass
3141
3142         return 'UNNAMED PROJECT'
3143
3144
3145 class GenericEnvironmentMixin(Environment):
3146     def get_pusher(self):
3147         return self.osenv.get('USER', self.osenv.get('USERNAME', 'unknown user'))
3148
3149
3150 class GitoliteEnvironmentHighPrecMixin(Environment):
3151     def get_pusher(self):
3152         return self.osenv.get('GL_USER', 'unknown user')
3153
3154
3155 class GitoliteEnvironmentLowPrecMixin(Environment):
3156     def get_repo_shortname(self):
3157         # The gitolite environment variable $GL_REPO is a pretty good
3158         # repo_shortname (though it's probably not as good as a value
3159         # the user might have explicitly put in his config).
3160         return (
3161             self.osenv.get('GL_REPO', None) or
3162             super(GitoliteEnvironmentLowPrecMixin, self).get_repo_shortname()
3163             )
3164
3165     def get_fromaddr(self, change=None):
3166         GL_USER = self.osenv.get('GL_USER')
3167         if GL_USER is not None:
3168             # Find the path to gitolite.conf.  Note that gitolite v3
3169             # did away with the GL_ADMINDIR and GL_CONF environment
3170             # variables (they are now hard-coded).
3171             GL_ADMINDIR = self.osenv.get(
3172                 'GL_ADMINDIR',
3173                 os.path.expanduser(os.path.join('~', '.gitolite')))
3174             GL_CONF = self.osenv.get(
3175                 'GL_CONF',
3176                 os.path.join(GL_ADMINDIR, 'conf', 'gitolite.conf'))
3177             if os.path.isfile(GL_CONF):
3178                 f = open(GL_CONF, 'rU')
3179                 try:
3180                     in_user_emails_section = False
3181                     re_template = r'^\s*#\s*%s\s*$'
3182                     re_begin, re_user, re_end = (
3183                         re.compile(re_template % x)
3184                         for x in (
3185                             r'BEGIN\s+USER\s+EMAILS',
3186                             re.escape(GL_USER) + r'\s+(.*)',
3187                             r'END\s+USER\s+EMAILS',
3188                             ))
3189                     for l in f:
3190                         l = l.rstrip('\n')
3191                         if not in_user_emails_section:
3192                             if re_begin.match(l):
3193                                 in_user_emails_section = True
3194                             continue
3195                         if re_end.match(l):
3196                             break
3197                         m = re_user.match(l)
3198                         if m:
3199                             return m.group(1)
3200                 finally:
3201                     f.close()
3202         return super(GitoliteEnvironmentLowPrecMixin, self).get_fromaddr(change)
3203
3204
3205 class IncrementalDateTime(object):
3206     """Simple wrapper to give incremental date/times.
3207
3208     Each call will result in a date/time a second later than the
3209     previous call.  This can be used to falsify email headers, to
3210     increase the likelihood that email clients sort the emails
3211     correctly."""
3212
3213     def __init__(self):
3214         self.time = time.time()
3215         self.next = self.__next__  # Python 2 backward compatibility
3216
3217     def __next__(self):
3218         formatted = formatdate(self.time, True)
3219         self.time += 1
3220         return formatted
3221
3222
3223 class StashEnvironmentHighPrecMixin(Environment):
3224     def __init__(self, user=None, repo=None, **kw):
3225         super(StashEnvironmentHighPrecMixin,
3226               self).__init__(user=user, repo=repo, **kw)
3227         self.__user = user
3228         self.__repo = repo
3229
3230     def get_pusher(self):
3231         return re.match('(.*?)\s*<', self.__user).group(1)
3232
3233     def get_pusher_email(self):
3234         return self.__user
3235
3236
3237 class StashEnvironmentLowPrecMixin(Environment):
3238     def __init__(self, user=None, repo=None, **kw):
3239         super(StashEnvironmentLowPrecMixin, self).__init__(**kw)
3240         self.__repo = repo
3241         self.__user = user
3242
3243     def get_repo_shortname(self):
3244         return self.__repo
3245
3246     def get_fromaddr(self, change=None):
3247         return self.__user
3248
3249
3250 class GerritEnvironmentHighPrecMixin(Environment):
3251     def __init__(self, project=None, submitter=None, update_method=None, **kw):
3252         super(GerritEnvironmentHighPrecMixin,
3253               self).__init__(submitter=submitter, project=project, **kw)
3254         self.__project = project
3255         self.__submitter = submitter
3256         self.__update_method = update_method
3257         "Make an 'update_method' value available for templates."
3258         self.COMPUTED_KEYS += ['update_method']
3259
3260     def get_pusher(self):
3261         if self.__submitter:
3262             if self.__submitter.find('<') != -1:
3263                 # Submitter has a configured email, we transformed
3264                 # __submitter into an RFC 2822 string already.
3265                 return re.match('(.*?)\s*<', self.__submitter).group(1)
3266             else:
3267                 # Submitter has no configured email, it's just his name.
3268                 return self.__submitter
3269         else:
3270             # If we arrive here, this means someone pushed "Submit" from
3271             # the gerrit web UI for the CR (or used one of the programmatic
3272             # APIs to do the same, such as gerrit review) and the
3273             # merge/push was done by the Gerrit user.  It was technically
3274             # triggered by someone else, but sadly we have no way of
3275             # determining who that someone else is at this point.
3276             return 'Gerrit'  # 'unknown user'?
3277
3278     def get_pusher_email(self):
3279         if self.__submitter:
3280             return self.__submitter
3281         else:
3282             return super(GerritEnvironmentHighPrecMixin, self).get_pusher_email()
3283
3284     def get_default_ref_ignore_regex(self):
3285         default = super(GerritEnvironmentHighPrecMixin, self).get_default_ref_ignore_regex()
3286         return default + '|^refs/changes/|^refs/cache-automerge/|^refs/meta/'
3287
3288     def get_revision_recipients(self, revision):
3289         # Merge commits created by Gerrit when users hit "Submit this patchset"
3290         # in the Web UI (or do equivalently with REST APIs or the gerrit review
3291         # command) are not something users want to see an individual email for.
3292         # Filter them out.
3293         committer = read_git_output(['log', '--no-walk', '--format=%cN',
3294                                      revision.rev.sha1])
3295         if committer == 'Gerrit Code Review':
3296             return []
3297         else:
3298             return super(GerritEnvironmentHighPrecMixin, self).get_revision_recipients(revision)
3299
3300     def get_update_method(self):
3301         return self.__update_method
3302
3303
3304 class GerritEnvironmentLowPrecMixin(Environment):
3305     def __init__(self, project=None, submitter=None, **kw):
3306         super(GerritEnvironmentLowPrecMixin, self).__init__(**kw)
3307         self.__project = project
3308         self.__submitter = submitter
3309
3310     def get_repo_shortname(self):
3311         return self.__project
3312
3313     def get_fromaddr(self, change=None):
3314         if self.__submitter and self.__submitter.find('<') != -1:
3315             return self.__submitter
3316         else:
3317             return super(GerritEnvironmentLowPrecMixin, self).get_fromaddr(change)
3318
3319
3320 class Push(object):
3321     """Represent an entire push (i.e., a group of ReferenceChanges).
3322
3323     It is easy to figure out what commits were added to a *branch* by
3324     a Reference change:
3325
3326         git rev-list change.old..change.new
3327
3328     or removed from a *branch*:
3329
3330         git rev-list change.new..change.old
3331
3332     But it is not quite so trivial to determine which entirely new
3333     commits were added to the *repository* by a push and which old
3334     commits were discarded by a push.  A big part of the job of this
3335     class is to figure out these things, and to make sure that new
3336     commits are only detailed once even if they were added to multiple
3337     references.
3338
3339     The first step is to determine the "other" references--those
3340     unaffected by the current push.  They are computed by listing all
3341     references then removing any affected by this push.  The results
3342     are stored in Push._other_ref_sha1s.
3343
3344     The commits contained in the repository before this push were
3345
3346         git rev-list other1 other2 other3 ... change1.old change2.old ...
3347
3348     Where "changeN.old" is the old value of one of the references
3349     affected by this push.
3350
3351     The commits contained in the repository after this push are
3352
3353         git rev-list other1 other2 other3 ... change1.new change2.new ...
3354
3355     The commits added by this push are the difference between these
3356     two sets, which can be written
3357
3358         git rev-list \
3359             ^other1 ^other2 ... \
3360             ^change1.old ^change2.old ... \
3361             change1.new change2.new ...
3362
3363     The commits removed by this push can be computed by
3364
3365         git rev-list \
3366             ^other1 ^other2 ... \
3367             ^change1.new ^change2.new ... \
3368             change1.old change2.old ...
3369
3370     The last point is that it is possible that other pushes are
3371     occurring simultaneously to this one, so reference values can
3372     change at any time.  It is impossible to eliminate all race
3373     conditions, but we reduce the window of time during which problems
3374     can occur by translating reference names to SHA1s as soon as
3375     possible and working with SHA1s thereafter (because SHA1s are
3376     immutable)."""
3377
3378     # A map {(changeclass, changetype): integer} specifying the order
3379     # that reference changes will be processed if multiple reference
3380     # changes are included in a single push.  The order is significant
3381     # mostly because new commit notifications are threaded together
3382     # with the first reference change that includes the commit.  The
3383     # following order thus causes commits to be grouped with branch
3384     # changes (as opposed to tag changes) if possible.
3385     SORT_ORDER = dict(
3386         (value, i) for (i, value) in enumerate([
3387             (BranchChange, 'update'),
3388             (BranchChange, 'create'),
3389             (AnnotatedTagChange, 'update'),
3390             (AnnotatedTagChange, 'create'),
3391             (NonAnnotatedTagChange, 'update'),
3392             (NonAnnotatedTagChange, 'create'),
3393             (BranchChange, 'delete'),
3394             (AnnotatedTagChange, 'delete'),
3395             (NonAnnotatedTagChange, 'delete'),
3396             (OtherReferenceChange, 'update'),
3397             (OtherReferenceChange, 'create'),
3398             (OtherReferenceChange, 'delete'),
3399             ])
3400         )
3401
3402     def __init__(self, environment, changes, ignore_other_refs=False):
3403         self.changes = sorted(changes, key=self._sort_key)
3404         self.__other_ref_sha1s = None
3405         self.__cached_commits_spec = {}
3406         self.environment = environment
3407
3408         if ignore_other_refs:
3409             self.__other_ref_sha1s = set()
3410
3411     @classmethod
3412     def _sort_key(klass, change):
3413         return (klass.SORT_ORDER[change.__class__, change.change_type], change.refname,)
3414
3415     @property
3416     def _other_ref_sha1s(self):
3417         """The GitObjects referred to by references unaffected by this push.
3418         """
3419         if self.__other_ref_sha1s is None:
3420             # The refnames being changed by this push:
3421             updated_refs = set(
3422                 change.refname
3423                 for change in self.changes
3424                 )
3425
3426             # The SHA-1s of commits referred to by all references in this
3427             # repository *except* updated_refs:
3428             sha1s = set()
3429             fmt = (
3430                 '%(objectname) %(objecttype) %(refname)\n'
3431                 '%(*objectname) %(*objecttype) %(refname)'
3432                 )
3433             ref_filter_regex, is_inclusion_filter = \
3434                 self.environment.get_ref_filter_regex()
3435             for line in read_git_lines(
3436                     ['for-each-ref', '--format=%s' % (fmt,)]):
3437                 (sha1, type, name) = line.split(' ', 2)
3438                 if (sha1 and type == 'commit' and
3439                         name not in updated_refs and
3440                         include_ref(name, ref_filter_regex, is_inclusion_filter)):
3441                     sha1s.add(sha1)
3442
3443             self.__other_ref_sha1s = sha1s
3444
3445         return self.__other_ref_sha1s
3446
3447     def _get_commits_spec_incl(self, new_or_old, reference_change=None):
3448         """Get new or old SHA-1 from one or each of the changed refs.
3449
3450         Return a list of SHA-1 commit identifier strings suitable as
3451         arguments to 'git rev-list' (or 'git log' or ...).  The
3452         returned identifiers are either the old or new values from one
3453         or all of the changed references, depending on the values of
3454         new_or_old and reference_change.
3455
3456         new_or_old is either the string 'new' or the string 'old'.  If
3457         'new', the returned SHA-1 identifiers are the new values from
3458         each changed reference.  If 'old', the SHA-1 identifiers are
3459         the old values from each changed reference.
3460
3461         If reference_change is specified and not None, only the new or
3462         old reference from the specified reference is included in the
3463         return value.
3464
3465         This function returns None if there are no matching revisions
3466         (e.g., because a branch was deleted and new_or_old is 'new').
3467         """
3468
3469         if not reference_change:
3470             incl_spec = sorted(
3471                 getattr(change, new_or_old).sha1
3472                 for change in self.changes
3473                 if getattr(change, new_or_old)
3474                 )
3475             if not incl_spec:
3476                 incl_spec = None
3477         elif not getattr(reference_change, new_or_old).commit_sha1:
3478             incl_spec = None
3479         else:
3480             incl_spec = [getattr(reference_change, new_or_old).commit_sha1]
3481         return incl_spec
3482
3483     def _get_commits_spec_excl(self, new_or_old):
3484         """Get exclusion revisions for determining new or discarded commits.
3485
3486         Return a list of strings suitable as arguments to 'git
3487         rev-list' (or 'git log' or ...) that will exclude all
3488         commits that, depending on the value of new_or_old, were
3489         either previously in the repository (useful for determining
3490         which commits are new to the repository) or currently in the
3491         repository (useful for determining which commits were
3492         discarded from the repository).
3493
3494         new_or_old is either the string 'new' or the string 'old'.  If
3495         'new', the commits to be excluded are those that were in the
3496         repository before the push.  If 'old', the commits to be
3497         excluded are those that are currently in the repository.  """
3498
3499         old_or_new = {'old': 'new', 'new': 'old'}[new_or_old]
3500         excl_revs = self._other_ref_sha1s.union(
3501             getattr(change, old_or_new).sha1
3502             for change in self.changes
3503             if getattr(change, old_or_new).type in ['commit', 'tag']
3504             )
3505         return ['^' + sha1 for sha1 in sorted(excl_revs)]
3506
3507     def get_commits_spec(self, new_or_old, reference_change=None):
3508         """Get rev-list arguments for added or discarded commits.
3509
3510         Return a list of strings suitable as arguments to 'git
3511         rev-list' (or 'git log' or ...) that select those commits
3512         that, depending on the value of new_or_old, are either new to
3513         the repository or were discarded from the repository.
3514
3515         new_or_old is either the string 'new' or the string 'old'.  If
3516         'new', the returned list is used to select commits that are
3517         new to the repository.  If 'old', the returned value is used
3518         to select the commits that have been discarded from the
3519         repository.
3520
3521         If reference_change is specified and not None, the new or
3522         discarded commits are limited to those that are reachable from
3523         the new or old value of the specified reference.
3524
3525         This function returns None if there are no added (or discarded)
3526         revisions.
3527         """
3528         key = (new_or_old, reference_change)
3529         if key not in self.__cached_commits_spec:
3530             ret = self._get_commits_spec_incl(new_or_old, reference_change)
3531             if ret is not None:
3532                 ret.extend(self._get_commits_spec_excl(new_or_old))
3533             self.__cached_commits_spec[key] = ret
3534         return self.__cached_commits_spec[key]
3535
3536     def get_new_commits(self, reference_change=None):
3537         """Return a list of commits added by this push.
3538
3539         Return a list of the object names of commits that were added
3540         by the part of this push represented by reference_change.  If
3541         reference_change is None, then return a list of *all* commits
3542         added by this push."""
3543
3544         spec = self.get_commits_spec('new', reference_change)
3545         return git_rev_list(spec)
3546
3547     def get_discarded_commits(self, reference_change):
3548         """Return a list of commits discarded by this push.
3549
3550         Return a list of the object names of commits that were
3551         entirely discarded from the repository by the part of this
3552         push represented by reference_change."""
3553
3554         spec = self.get_commits_spec('old', reference_change)
3555         return git_rev_list(spec)
3556
3557     def send_emails(self, mailer, body_filter=None):
3558         """Use send all of the notification emails needed for this push.
3559
3560         Use send all of the notification emails (including reference
3561         change emails and commit emails) needed for this push.  Send
3562         the emails using mailer.  If body_filter is not None, then use
3563         it to filter the lines that are intended for the email
3564         body."""
3565
3566         # The sha1s of commits that were introduced by this push.
3567         # They will be removed from this set as they are processed, to
3568         # guarantee that one (and only one) email is generated for
3569         # each new commit.
3570         unhandled_sha1s = set(self.get_new_commits())
3571         send_date = IncrementalDateTime()
3572         for change in self.changes:
3573             sha1s = []
3574             for sha1 in reversed(list(self.get_new_commits(change))):
3575                 if sha1 in unhandled_sha1s:
3576                     sha1s.append(sha1)
3577                     unhandled_sha1s.remove(sha1)
3578
3579             # Check if we've got anyone to send to
3580             if not change.recipients:
3581                 change.environment.log_warning(
3582                     '*** no recipients configured so no email will be sent\n'
3583                     '*** for %r update %s->%s'
3584                     % (change.refname, change.old.sha1, change.new.sha1,)
3585                     )
3586             else:
3587                 if not change.environment.quiet:
3588                     change.environment.log_msg(
3589                         'Sending notification emails to: %s' % (change.recipients,))
3590                 extra_values = {'send_date': next(send_date)}
3591
3592                 rev = change.send_single_combined_email(sha1s)
3593                 if rev:
3594                     mailer.send(
3595                         change.generate_combined_email(self, rev, body_filter, extra_values),
3596                         rev.recipients,
3597                         )
3598                     # This change is now fully handled; no need to handle
3599                     # individual revisions any further.
3600                     continue
3601                 else:
3602                     mailer.send(
3603                         change.generate_email(self, body_filter, extra_values),
3604                         change.recipients,
3605                         )
3606
3607             max_emails = change.environment.maxcommitemails
3608             if max_emails and len(sha1s) > max_emails:
3609                 change.environment.log_warning(
3610                     '*** Too many new commits (%d), not sending commit emails.\n' % len(sha1s) +
3611                     '*** Try setting multimailhook.maxCommitEmails to a greater value\n' +
3612                     '*** Currently, multimailhook.maxCommitEmails=%d' % max_emails
3613                     )
3614                 return
3615
3616             for (num, sha1) in enumerate(sha1s):
3617                 rev = Revision(change, GitObject(sha1), num=num + 1, tot=len(sha1s))
3618                 if not rev.recipients and rev.cc_recipients:
3619                     change.environment.log_msg('*** Replacing Cc: with To:')
3620                     rev.recipients = rev.cc_recipients
3621                     rev.cc_recipients = None
3622                 if rev.recipients:
3623                     extra_values = {'send_date': next(send_date)}
3624                     mailer.send(
3625                         rev.generate_email(self, body_filter, extra_values),
3626                         rev.recipients,
3627                         )
3628
3629         # Consistency check:
3630         if unhandled_sha1s:
3631             change.environment.log_error(
3632                 'ERROR: No emails were sent for the following new commits:\n'
3633                 '    %s'
3634                 % ('\n    '.join(sorted(unhandled_sha1s)),)
3635                 )
3636
3637
3638 def include_ref(refname, ref_filter_regex, is_inclusion_filter):
3639     does_match = bool(ref_filter_regex.search(refname))
3640     if is_inclusion_filter:
3641         return does_match
3642     else:  # exclusion filter -- we include the ref if the regex doesn't match
3643         return not does_match
3644
3645
3646 def run_as_post_receive_hook(environment, mailer):
3647     environment.check()
3648     send_filter_regex, send_is_inclusion_filter = environment.get_ref_filter_regex(True)
3649     ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(False)
3650     changes = []
3651     while True:
3652         line = read_line(sys.stdin)
3653         if line == '':
3654             break
3655         (oldrev, newrev, refname) = line.strip().split(' ', 2)
3656         environment.get_logger().debug(
3657             "run_as_post_receive_hook: oldrev=%s, newrev=%s, refname=%s" %
3658             (oldrev, newrev, refname))
3659
3660         if not include_ref(refname, ref_filter_regex, is_inclusion_filter):
3661             continue
3662         if not include_ref(refname, send_filter_regex, send_is_inclusion_filter):
3663             continue
3664         changes.append(
3665             ReferenceChange.create(environment, oldrev, newrev, refname)
3666             )
3667     if changes:
3668         push = Push(environment, changes)
3669         push.send_emails(mailer, body_filter=environment.filter_body)
3670     if hasattr(mailer, '__del__'):
3671         mailer.__del__()
3672
3673
3674 def run_as_update_hook(environment, mailer, refname, oldrev, newrev, force_send=False):
3675     environment.check()
3676     send_filter_regex, send_is_inclusion_filter = environment.get_ref_filter_regex(True)
3677     ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(False)
3678     if not include_ref(refname, ref_filter_regex, is_inclusion_filter):
3679         return
3680     if not include_ref(refname, send_filter_regex, send_is_inclusion_filter):
3681         return
3682     changes = [
3683         ReferenceChange.create(
3684             environment,
3685             read_git_output(['rev-parse', '--verify', oldrev]),
3686             read_git_output(['rev-parse', '--verify', newrev]),
3687             refname,
3688             ),
3689         ]
3690     push = Push(environment, changes, force_send)
3691     push.send_emails(mailer, body_filter=environment.filter_body)
3692     if hasattr(mailer, '__del__'):
3693         mailer.__del__()
3694
3695
3696 def check_ref_filter(environment):
3697     send_filter_regex, send_is_inclusion = environment.get_ref_filter_regex(True)
3698     ref_filter_regex, ref_is_inclusion = environment.get_ref_filter_regex(False)
3699
3700     def inc_exc_lusion(b):
3701         if b:
3702             return 'inclusion'
3703         else:
3704             return 'exclusion'
3705
3706     if send_filter_regex:
3707         sys.stdout.write("DoSend/DontSend filter regex (" +
3708                          (inc_exc_lusion(send_is_inclusion)) +
3709                          '): ' + send_filter_regex.pattern +
3710                          '\n')
3711     if send_filter_regex:
3712         sys.stdout.write("Include/Exclude filter regex (" +
3713                          (inc_exc_lusion(ref_is_inclusion)) +
3714                          '): ' + ref_filter_regex.pattern +
3715                          '\n')
3716     sys.stdout.write(os.linesep)
3717
3718     sys.stdout.write(
3719         "Refs marked as EXCLUDE are excluded by either refFilterInclusionRegex\n"
3720         "or refFilterExclusionRegex. No emails will be sent for commits included\n"
3721         "in these refs.\n"
3722         "Refs marked as DONT-SEND are excluded by either refFilterDoSendRegex or\n"
3723         "refFilterDontSendRegex, but not by either refFilterInclusionRegex or\n"
3724         "refFilterExclusionRegex. Emails will be sent for commits included in these\n"
3725         "refs only when the commit reaches a ref which isn't excluded.\n"
3726         "Refs marked as DO-SEND are not excluded by any filter. Emails will\n"
3727         "be sent normally for commits included in these refs.\n")
3728
3729     sys.stdout.write(os.linesep)
3730
3731     for refname in read_git_lines(['for-each-ref', '--format', '%(refname)']):
3732         sys.stdout.write(refname)
3733         if not include_ref(refname, ref_filter_regex, ref_is_inclusion):
3734             sys.stdout.write(' EXCLUDE')
3735         elif not include_ref(refname, send_filter_regex, send_is_inclusion):
3736             sys.stdout.write(' DONT-SEND')
3737         else:
3738             sys.stdout.write(' DO-SEND')
3739
3740         sys.stdout.write(os.linesep)
3741
3742
3743 def show_env(environment, out):
3744     out.write('Environment values:\n')
3745     for (k, v) in sorted(environment.get_values().items()):
3746         if k:  # Don't show the {'' : ''} pair.
3747             out.write('    %s : %r\n' % (k, v))
3748     out.write('\n')
3749     # Flush to avoid interleaving with further log output
3750     out.flush()
3751
3752
3753 def check_setup(environment):
3754     environment.check()
3755     show_env(environment, sys.stdout)
3756     sys.stdout.write("Now, checking that git-multimail's standard input "
3757                      "is properly set ..." + os.linesep)
3758     sys.stdout.write("Please type some text and then press Return" + os.linesep)
3759     stdin = sys.stdin.readline()
3760     sys.stdout.write("You have just entered:" + os.linesep)
3761     sys.stdout.write(stdin)
3762     sys.stdout.write("git-multimail seems properly set up." + os.linesep)
3763
3764
3765 def choose_mailer(config, environment):
3766     mailer = config.get('mailer', default='sendmail')
3767
3768     if mailer == 'smtp':
3769         smtpserver = config.get('smtpserver', default='localhost')
3770         smtpservertimeout = float(config.get('smtpservertimeout', default=10.0))
3771         smtpserverdebuglevel = int(config.get('smtpserverdebuglevel', default=0))
3772         smtpencryption = config.get('smtpencryption', default='none')
3773         smtpuser = config.get('smtpuser', default='')
3774         smtppass = config.get('smtppass', default='')
3775         smtpcacerts = config.get('smtpcacerts', default='')
3776         mailer = SMTPMailer(
3777             environment,
3778             envelopesender=(environment.get_sender() or environment.get_fromaddr()),
3779             smtpserver=smtpserver, smtpservertimeout=smtpservertimeout,
3780             smtpserverdebuglevel=smtpserverdebuglevel,
3781             smtpencryption=smtpencryption,
3782             smtpuser=smtpuser,
3783             smtppass=smtppass,
3784             smtpcacerts=smtpcacerts
3785             )
3786     elif mailer == 'sendmail':
3787         command = config.get('sendmailcommand')
3788         if command:
3789             command = shlex.split(command)
3790         mailer = SendMailer(environment,
3791                             command=command, envelopesender=environment.get_sender())
3792     else:
3793         environment.log_error(
3794             'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n' % mailer +
3795             'please use one of "smtp" or "sendmail".'
3796             )
3797         sys.exit(1)
3798     return mailer
3799
3800
3801 KNOWN_ENVIRONMENTS = {
3802     'generic': {'highprec': GenericEnvironmentMixin},
3803     'gitolite': {'highprec': GitoliteEnvironmentHighPrecMixin,
3804                  'lowprec': GitoliteEnvironmentLowPrecMixin},
3805     'stash': {'highprec': StashEnvironmentHighPrecMixin,
3806               'lowprec': StashEnvironmentLowPrecMixin},
3807     'gerrit': {'highprec': GerritEnvironmentHighPrecMixin,
3808                'lowprec': GerritEnvironmentLowPrecMixin},
3809     }
3810
3811
3812 def choose_environment(config, osenv=None, env=None, recipients=None,
3813                        hook_info=None):
3814     env_name = choose_environment_name(config, env, osenv)
3815     environment_klass = build_environment_klass(env_name)
3816     env = build_environment(environment_klass, env_name, config,
3817                             osenv, recipients, hook_info)
3818     return env
3819
3820
3821 def choose_environment_name(config, env, osenv):
3822     if not osenv:
3823         osenv = os.environ
3824
3825     if not env:
3826         env = config.get('environment')
3827
3828     if not env:
3829         if 'GL_USER' in osenv and 'GL_REPO' in osenv:
3830             env = 'gitolite'
3831         else:
3832             env = 'generic'
3833     return env
3834
3835
3836 COMMON_ENVIRONMENT_MIXINS = [
3837     ConfigRecipientsEnvironmentMixin,
3838     CLIRecipientsEnvironmentMixin,
3839     ConfigRefFilterEnvironmentMixin,
3840     ProjectdescEnvironmentMixin,
3841     ConfigMaxlinesEnvironmentMixin,
3842     ComputeFQDNEnvironmentMixin,
3843     ConfigFilterLinesEnvironmentMixin,
3844     PusherDomainEnvironmentMixin,
3845     ConfigOptionsEnvironmentMixin,
3846     ]
3847
3848
3849 def build_environment_klass(env_name):
3850     if 'class' in KNOWN_ENVIRONMENTS[env_name]:
3851         return KNOWN_ENVIRONMENTS[env_name]['class']
3852
3853     environment_mixins = []
3854     known_env = KNOWN_ENVIRONMENTS[env_name]
3855     if 'highprec' in known_env:
3856         high_prec_mixin = known_env['highprec']
3857         environment_mixins.append(high_prec_mixin)
3858     environment_mixins = environment_mixins + COMMON_ENVIRONMENT_MIXINS
3859     if 'lowprec' in known_env:
3860         low_prec_mixin = known_env['lowprec']
3861         environment_mixins.append(low_prec_mixin)
3862     environment_mixins.append(Environment)
3863     klass_name = env_name.capitalize() + 'Environement'
3864     environment_klass = type(
3865         klass_name,
3866         tuple(environment_mixins),
3867         {},
3868         )
3869     KNOWN_ENVIRONMENTS[env_name]['class'] = environment_klass
3870     return environment_klass
3871
3872
3873 GerritEnvironment = build_environment_klass('gerrit')
3874 StashEnvironment = build_environment_klass('stash')
3875 GitoliteEnvironment = build_environment_klass('gitolite')
3876 GenericEnvironment = build_environment_klass('generic')
3877
3878
3879 def build_environment(environment_klass, env, config,
3880                       osenv, recipients, hook_info):
3881     environment_kw = {
3882         'osenv': osenv,
3883         'config': config,
3884         }
3885
3886     if env == 'stash':
3887         environment_kw['user'] = hook_info['stash_user']
3888         environment_kw['repo'] = hook_info['stash_repo']
3889     elif env == 'gerrit':
3890         environment_kw['project'] = hook_info['project']
3891         environment_kw['submitter'] = hook_info['submitter']
3892         environment_kw['update_method'] = hook_info['update_method']
3893
3894     environment_kw['cli_recipients'] = recipients
3895
3896     return environment_klass(**environment_kw)
3897
3898
3899 def get_version():
3900     oldcwd = os.getcwd()
3901     try:
3902         try:
3903             os.chdir(os.path.dirname(os.path.realpath(__file__)))
3904             git_version = read_git_output(['describe', '--tags', 'HEAD'])
3905             if git_version == __version__:
3906                 return git_version
3907             else:
3908                 return '%s (%s)' % (__version__, git_version)
3909         except:
3910             pass
3911     finally:
3912         os.chdir(oldcwd)
3913     return __version__
3914
3915
3916 def compute_gerrit_options(options, args, required_gerrit_options,
3917                            raw_refname):
3918     if None in required_gerrit_options:
3919         raise SystemExit("Error: Specify all of --oldrev, --newrev, --refname, "
3920                          "and --project; or none of them.")
3921
3922     if options.environment not in (None, 'gerrit'):
3923         raise SystemExit("Non-gerrit environments incompatible with --oldrev, "
3924                          "--newrev, --refname, and --project")
3925     options.environment = 'gerrit'
3926
3927     if args:
3928         raise SystemExit("Error: Positional parameters not allowed with "
3929                          "--oldrev, --newrev, and --refname.")
3930
3931     # Gerrit oddly omits 'refs/heads/' in the refname when calling
3932     # ref-updated hook; put it back.
3933     git_dir = get_git_dir()
3934     if (not os.path.exists(os.path.join(git_dir, raw_refname)) and
3935         os.path.exists(os.path.join(git_dir, 'refs', 'heads',
3936                                     raw_refname))):
3937         options.refname = 'refs/heads/' + options.refname
3938
3939     # New revisions can appear in a gerrit repository either due to someone
3940     # pushing directly (in which case options.submitter will be set), or they
3941     # can press "Submit this patchset" in the web UI for some CR (in which
3942     # case options.submitter will not be set and gerrit will not have provided
3943     # us the information about who pressed the button).
3944     #
3945     # Note for the nit-picky: I'm lumping in REST API calls and the ssh
3946     # gerrit review command in with "Submit this patchset" button, since they
3947     # have the same effect.
3948     if options.submitter:
3949         update_method = 'pushed'
3950         # The submitter argument is almost an RFC 2822 email address; change it
3951         # from 'User Name (email@domain)' to 'User Name <email@domain>' so it is
3952         options.submitter = options.submitter.replace('(', '<').replace(')', '>')
3953     else:
3954         update_method = 'submitted'
3955         # Gerrit knew who submitted this patchset, but threw that information
3956         # away when it invoked this hook.  However, *IF* Gerrit created a
3957         # merge to bring the patchset in (project 'Submit Type' is either
3958         # "Always Merge", or is "Merge if Necessary" and happens to be
3959         # necessary for this particular CR), then it will have the committer
3960         # of that merge be 'Gerrit Code Review' and the author will be the
3961         # person who requested the submission of the CR.  Since this is fairly
3962         # likely for most gerrit installations (of a reasonable size), it's
3963         # worth the extra effort to try to determine the actual submitter.
3964         rev_info = read_git_lines(['log', '--no-walk', '--merges',
3965                                    '--format=%cN%n%aN <%aE>', options.newrev])
3966         if rev_info and rev_info[0] == 'Gerrit Code Review':
3967             options.submitter = rev_info[1]
3968
3969     # We pass back refname, oldrev, newrev as args because then the
3970     # gerrit ref-updated hook is much like the git update hook
3971     return (options,
3972             [options.refname, options.oldrev, options.newrev],
3973             {'project': options.project, 'submitter': options.submitter,
3974              'update_method': update_method})
3975
3976
3977 def check_hook_specific_args(options, args):
3978     raw_refname = options.refname
3979     # Convert each string option unicode for Python3.
3980     if PYTHON3:
3981         opts = ['environment', 'recipients', 'oldrev', 'newrev', 'refname',
3982                 'project', 'submitter', 'stash_user', 'stash_repo']
3983         for opt in opts:
3984             if not hasattr(options, opt):
3985                 continue
3986             obj = getattr(options, opt)
3987             if obj:
3988                 enc = obj.encode('utf-8', 'surrogateescape')
3989                 dec = enc.decode('utf-8', 'replace')
3990                 setattr(options, opt, dec)
3991
3992     # First check for stash arguments
3993     if (options.stash_user is None) != (options.stash_repo is None):
3994         raise SystemExit("Error: Specify both of --stash-user and "
3995                          "--stash-repo or neither.")
3996     if options.stash_user:
3997         options.environment = 'stash'
3998         return options, args, {'stash_user': options.stash_user,
3999                                'stash_repo': options.stash_repo}
4000
4001     # Finally, check for gerrit specific arguments
4002     required_gerrit_options = (options.oldrev, options.newrev, options.refname,
4003                                options.project)
4004     if required_gerrit_options != (None,) * 4:
4005         return compute_gerrit_options(options, args, required_gerrit_options,
4006                                       raw_refname)
4007
4008     # No special options in use, just return what we started with
4009     return options, args, {}
4010
4011
4012 class Logger(object):
4013     def parse_verbose(self, verbose):
4014         if verbose > 0:
4015             return logging.DEBUG
4016         else:
4017             return logging.INFO
4018
4019     def create_log_file(self, environment, name, path, verbosity):
4020         log_file = logging.getLogger(name)
4021         file_handler = logging.FileHandler(path)
4022         log_fmt = logging.Formatter("%(asctime)s [%(levelname)-5.5s]  %(message)s")
4023         file_handler.setFormatter(log_fmt)
4024         log_file.addHandler(file_handler)
4025         log_file.setLevel(verbosity)
4026         return log_file
4027
4028     def __init__(self, environment):
4029         self.environment = environment
4030         self.loggers = []
4031         stderr_log = logging.getLogger('git_multimail.stderr')
4032
4033         class EncodedStderr(object):
4034             def write(self, x):
4035                 write_str(sys.stderr, x)
4036
4037             def flush(self):
4038                 sys.stderr.flush()
4039
4040         stderr_handler = logging.StreamHandler(EncodedStderr())
4041         stderr_log.addHandler(stderr_handler)
4042         stderr_log.setLevel(self.parse_verbose(environment.verbose))
4043         self.loggers.append(stderr_log)
4044
4045         if environment.debug_log_file is not None:
4046             debug_log_file = self.create_log_file(
4047                 environment, 'git_multimail.debug', environment.debug_log_file, logging.DEBUG)
4048             self.loggers.append(debug_log_file)
4049
4050         if environment.log_file is not None:
4051             log_file = self.create_log_file(
4052                 environment, 'git_multimail.file', environment.log_file, logging.INFO)
4053             self.loggers.append(log_file)
4054
4055         if environment.error_log_file is not None:
4056             error_log_file = self.create_log_file(
4057                 environment, 'git_multimail.error', environment.error_log_file, logging.ERROR)
4058             self.loggers.append(error_log_file)
4059
4060     def info(self, msg):
4061         for l in self.loggers:
4062             l.info(msg)
4063
4064     def debug(self, msg):
4065         for l in self.loggers:
4066             l.debug(msg)
4067
4068     def warning(self, msg):
4069         for l in self.loggers:
4070             l.warning(msg)
4071
4072     def error(self, msg):
4073         for l in self.loggers:
4074             l.error(msg)
4075
4076
4077 def main(args):
4078     parser = optparse.OptionParser(
4079         description=__doc__,
4080         usage='%prog [OPTIONS]\n   or: %prog [OPTIONS] REFNAME OLDREV NEWREV',
4081         )
4082
4083     parser.add_option(
4084         '--environment', '--env', action='store', type='choice',
4085         choices=list(KNOWN_ENVIRONMENTS.keys()), default=None,
4086         help=(
4087             'Choose type of environment is in use.  Default is taken from '
4088             'multimailhook.environment if set; otherwise "generic".'
4089             ),
4090         )
4091     parser.add_option(
4092         '--stdout', action='store_true', default=False,
4093         help='Output emails to stdout rather than sending them.',
4094         )
4095     parser.add_option(
4096         '--recipients', action='store', default=None,
4097         help='Set list of email recipients for all types of emails.',
4098         )
4099     parser.add_option(
4100         '--show-env', action='store_true', default=False,
4101         help=(
4102             'Write to stderr the values determined for the environment '
4103             '(intended for debugging purposes), then proceed normally.'
4104             ),
4105         )
4106     parser.add_option(
4107         '--force-send', action='store_true', default=False,
4108         help=(
4109             'Force sending refchange email when using as an update hook. '
4110             'This is useful to work around the unreliable new commits '
4111             'detection in this mode.'
4112             ),
4113         )
4114     parser.add_option(
4115         '-c', metavar="<name>=<value>", action='append',
4116         help=(
4117             'Pass a configuration parameter through to git.  The value given '
4118             'will override values from configuration files.  See the -c option '
4119             'of git(1) for more details.  (Only works with git >= 1.7.3)'
4120             ),
4121         )
4122     parser.add_option(
4123         '--version', '-v', action='store_true', default=False,
4124         help=(
4125             "Display git-multimail's version"
4126             ),
4127         )
4128
4129     parser.add_option(
4130         '--python-version', action='store_true', default=False,
4131         help=(
4132             "Display the version of Python used by git-multimail"
4133             ),
4134         )
4135
4136     parser.add_option(
4137         '--check-ref-filter', action='store_true', default=False,
4138         help=(
4139             'List refs and show information on how git-multimail '
4140             'will process them.'
4141             )
4142         )
4143
4144     # The following options permit this script to be run as a gerrit
4145     # ref-updated hook.  See e.g.
4146     # code.google.com/p/gerrit/source/browse/Documentation/config-hooks.txt
4147     # We suppress help for these items, since these are specific to gerrit,
4148     # and we don't want users directly using them any way other than how the
4149     # gerrit ref-updated hook is called.
4150     parser.add_option('--oldrev', action='store', help=optparse.SUPPRESS_HELP)
4151     parser.add_option('--newrev', action='store', help=optparse.SUPPRESS_HELP)
4152     parser.add_option('--refname', action='store', help=optparse.SUPPRESS_HELP)
4153     parser.add_option('--project', action='store', help=optparse.SUPPRESS_HELP)
4154     parser.add_option('--submitter', action='store', help=optparse.SUPPRESS_HELP)
4155
4156     # The following allow this to be run as a stash asynchronous post-receive
4157     # hook (almost identical to a git post-receive hook but triggered also for
4158     # merges of pull requests from the UI).  We suppress help for these items,
4159     # since these are specific to stash.
4160     parser.add_option('--stash-user', action='store', help=optparse.SUPPRESS_HELP)
4161     parser.add_option('--stash-repo', action='store', help=optparse.SUPPRESS_HELP)
4162
4163     (options, args) = parser.parse_args(args)
4164     (options, args, hook_info) = check_hook_specific_args(options, args)
4165
4166     if options.version:
4167         sys.stdout.write('git-multimail version ' + get_version() + '\n')
4168         return
4169
4170     if options.python_version:
4171         sys.stdout.write('Python version ' + sys.version + '\n')
4172         return
4173
4174     if options.c:
4175         Config.add_config_parameters(options.c)
4176
4177     config = Config('multimailhook')
4178
4179     environment = None
4180     try:
4181         environment = choose_environment(
4182             config, osenv=os.environ,
4183             env=options.environment,
4184             recipients=options.recipients,
4185             hook_info=hook_info,
4186             )
4187
4188         if options.show_env:
4189             show_env(environment, sys.stderr)
4190
4191         if options.stdout or environment.stdout:
4192             mailer = OutputMailer(sys.stdout)
4193         else:
4194             mailer = choose_mailer(config, environment)
4195
4196         must_check_setup = os.environ.get('GIT_MULTIMAIL_CHECK_SETUP')
4197         if must_check_setup == '':
4198             must_check_setup = False
4199         if options.check_ref_filter:
4200             check_ref_filter(environment)
4201         elif must_check_setup:
4202             check_setup(environment)
4203         # Dual mode: if arguments were specified on the command line, run
4204         # like an update hook; otherwise, run as a post-receive hook.
4205         elif args:
4206             if len(args) != 3:
4207                 parser.error('Need zero or three non-option arguments')
4208             (refname, oldrev, newrev) = args
4209             environment.get_logger().debug(
4210                 "run_as_update_hook: refname=%s, oldrev=%s, newrev=%s, force_send=%s" %
4211                 (refname, oldrev, newrev, options.force_send))
4212             run_as_update_hook(environment, mailer, refname, oldrev, newrev, options.force_send)
4213         else:
4214             run_as_post_receive_hook(environment, mailer)
4215     except ConfigurationException:
4216         sys.exit(sys.exc_info()[1])
4217     except SystemExit:
4218         raise
4219     except Exception:
4220         t, e, tb = sys.exc_info()
4221         import traceback
4222         sys.stderr.write('\n')  # Avoid mixing message with previous output
4223         msg = (
4224             'Exception \'' + t.__name__ +
4225             '\' raised. Please report this as a bug to\n'
4226             'https://github.com/git-multimail/git-multimail/issues\n'
4227             'with the information below:\n\n'
4228             'git-multimail version ' + get_version() + '\n'
4229             'Python version ' + sys.version + '\n' +
4230             traceback.format_exc())
4231         try:
4232             environment.get_logger().error(msg)
4233         except:
4234             sys.stderr.write(msg)
4235         sys.exit(1)
4236
4237 if __name__ == '__main__':
4238     main(sys.argv[1:])