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