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