Merge branch 'jk/push-client-deadlock-fix'
[git] / contrib / hooks / multimail / git_multimail.py
1 #! /usr/bin/env python
2
3 __version__ = '1.3.1'
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
1708         def revision_gen_link(base_url):
1709             # revision is used only to generate the body, and
1710             # _content_type is set while generating headers. Get it
1711             # from the BranchChange object.
1712             revision._content_type = self._content_type
1713             return revision.generate_browse_link(base_url)
1714         self.generate_browse_link = revision_gen_link
1715         for line in self.generate_email(push, body_filter, values):
1716             yield line
1717
1718     def generate_email_body(self, push):
1719         '''Call the appropriate body generation routine.
1720
1721         If this is a combined refchange/revision email, the special logic
1722         for handling this combined email comes from this function.  For
1723         other cases, we just use the normal handling.'''
1724
1725         # If self._single_revision isn't set; don't override
1726         if not self._single_revision:
1727             for line in super(BranchChange, self).generate_email_body(push):
1728                 yield line
1729             return
1730
1731         # This is a combined refchange/revision email; we first provide
1732         # some info from the refchange portion, and then call the revision
1733         # generate_email_body function to handle the revision portion.
1734         adds = list(generate_summaries(
1735             '--topo-order', '--reverse', '%s..%s'
1736             % (self.old.commit_sha1, self.new.commit_sha1,)
1737             ))
1738
1739         yield self.expand("The following commit(s) were added to %(refname)s by this push:\n")
1740         for (sha1, subject) in adds:
1741             yield self.expand(
1742                 BRIEF_SUMMARY_TEMPLATE, action='new',
1743                 rev_short=sha1, text=subject,
1744                 )
1745
1746         yield self._single_revision.rev.short + " is described below\n"
1747         yield '\n'
1748
1749         for line in self._single_revision.generate_email_body(push):
1750             yield line
1751
1752
1753 class AnnotatedTagChange(ReferenceChange):
1754     refname_type = 'annotated tag'
1755
1756     def __init__(self, environment, refname, short_refname, old, new, rev):
1757         ReferenceChange.__init__(
1758             self, environment,
1759             refname=refname, short_refname=short_refname,
1760             old=old, new=new, rev=rev,
1761             )
1762         self.recipients = environment.get_announce_recipients(self)
1763         self.show_shortlog = environment.announce_show_shortlog
1764
1765     ANNOTATED_TAG_FORMAT = (
1766         '%(*objectname)\n'
1767         '%(*objecttype)\n'
1768         '%(taggername)\n'
1769         '%(taggerdate)'
1770         )
1771
1772     def describe_tag(self, push):
1773         """Describe the new value of an annotated tag."""
1774
1775         # Use git for-each-ref to pull out the individual fields from
1776         # the tag
1777         [tagobject, tagtype, tagger, tagged] = read_git_lines(
1778             ['for-each-ref', '--format=%s' % (self.ANNOTATED_TAG_FORMAT,), self.refname],
1779             )
1780
1781         yield self.expand(
1782             BRIEF_SUMMARY_TEMPLATE, action='tagging',
1783             rev_short=tagobject, text='(%s)' % (tagtype,),
1784             )
1785         if tagtype == 'commit':
1786             # If the tagged object is a commit, then we assume this is a
1787             # release, and so we calculate which tag this tag is
1788             # replacing
1789             try:
1790                 prevtag = read_git_output(['describe', '--abbrev=0', '%s^' % (self.new,)])
1791             except CommandError:
1792                 prevtag = None
1793             if prevtag:
1794                 yield '  replaces  %s\n' % (prevtag,)
1795         else:
1796             prevtag = None
1797             yield '    length  %s bytes\n' % (read_git_output(['cat-file', '-s', tagobject]),)
1798
1799         yield ' tagged by  %s\n' % (tagger,)
1800         yield '        on  %s\n' % (tagged,)
1801         yield '\n'
1802
1803         # Show the content of the tag message; this might contain a
1804         # change log or release notes so is worth displaying.
1805         yield LOGBEGIN
1806         contents = list(read_git_lines(['cat-file', 'tag', self.new.sha1], keepends=True))
1807         contents = contents[contents.index('\n') + 1:]
1808         if contents and contents[-1][-1:] != '\n':
1809             contents.append('\n')
1810         for line in contents:
1811             yield line
1812
1813         if self.show_shortlog and tagtype == 'commit':
1814             # Only commit tags make sense to have rev-list operations
1815             # performed on them
1816             yield '\n'
1817             if prevtag:
1818                 # Show changes since the previous release
1819                 revlist = read_git_output(
1820                     ['rev-list', '--pretty=short', '%s..%s' % (prevtag, self.new,)],
1821                     keepends=True,
1822                     )
1823             else:
1824                 # No previous tag, show all the changes since time
1825                 # began
1826                 revlist = read_git_output(
1827                     ['rev-list', '--pretty=short', '%s' % (self.new,)],
1828                     keepends=True,
1829                     )
1830             for line in read_git_lines(['shortlog'], input=revlist, keepends=True):
1831                 yield line
1832
1833         yield LOGEND
1834         yield '\n'
1835
1836     def generate_create_summary(self, push):
1837         """Called for the creation of an annotated tag."""
1838
1839         for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1840             yield line
1841
1842         for line in self.describe_tag(push):
1843             yield line
1844
1845     def generate_update_summary(self, push):
1846         """Called for the update of an annotated tag.
1847
1848         This is probably a rare event and may not even be allowed."""
1849
1850         for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1851             yield line
1852
1853         for line in self.describe_tag(push):
1854             yield line
1855
1856     def generate_delete_summary(self, push):
1857         """Called when a non-annotated reference is updated."""
1858
1859         for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1860             yield line
1861
1862         yield self.expand('   tag was  %(oldrev_short)s\n')
1863         yield '\n'
1864
1865
1866 class NonAnnotatedTagChange(ReferenceChange):
1867     refname_type = 'tag'
1868
1869     def __init__(self, environment, refname, short_refname, old, new, rev):
1870         ReferenceChange.__init__(
1871             self, environment,
1872             refname=refname, short_refname=short_refname,
1873             old=old, new=new, rev=rev,
1874             )
1875         self.recipients = environment.get_refchange_recipients(self)
1876
1877     def generate_create_summary(self, push):
1878         """Called for the creation of an annotated tag."""
1879
1880         for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1881             yield line
1882
1883     def generate_update_summary(self, push):
1884         """Called when a non-annotated reference is updated."""
1885
1886         for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1887             yield line
1888
1889     def generate_delete_summary(self, push):
1890         """Called when a non-annotated reference is updated."""
1891
1892         for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1893             yield line
1894
1895         for line in ReferenceChange.generate_delete_summary(self, push):
1896             yield line
1897
1898
1899 class OtherReferenceChange(ReferenceChange):
1900     refname_type = 'reference'
1901
1902     def __init__(self, environment, refname, short_refname, old, new, rev):
1903         # We use the full refname as short_refname, because otherwise
1904         # the full name of the reference would not be obvious from the
1905         # text of the email.
1906         ReferenceChange.__init__(
1907             self, environment,
1908             refname=refname, short_refname=refname,
1909             old=old, new=new, rev=rev,
1910             )
1911         self.recipients = environment.get_refchange_recipients(self)
1912
1913
1914 class Mailer(object):
1915     """An object that can send emails."""
1916
1917     def send(self, lines, to_addrs):
1918         """Send an email consisting of lines.
1919
1920         lines must be an iterable over the lines constituting the
1921         header and body of the email.  to_addrs is a list of recipient
1922         addresses (can be needed even if lines already contains a
1923         "To:" field).  It can be either a string (comma-separated list
1924         of email addresses) or a Python list of individual email
1925         addresses.
1926
1927         """
1928
1929         raise NotImplementedError()
1930
1931
1932 class SendMailer(Mailer):
1933     """Send emails using 'sendmail -oi -t'."""
1934
1935     SENDMAIL_CANDIDATES = [
1936         '/usr/sbin/sendmail',
1937         '/usr/lib/sendmail',
1938         ]
1939
1940     @staticmethod
1941     def find_sendmail():
1942         for path in SendMailer.SENDMAIL_CANDIDATES:
1943             if os.access(path, os.X_OK):
1944                 return path
1945         else:
1946             raise ConfigurationException(
1947                 'No sendmail executable found.  '
1948                 'Try setting multimailhook.sendmailCommand.'
1949                 )
1950
1951     def __init__(self, command=None, envelopesender=None):
1952         """Construct a SendMailer instance.
1953
1954         command should be the command and arguments used to invoke
1955         sendmail, as a list of strings.  If an envelopesender is
1956         provided, it will also be passed to the command, via '-f
1957         envelopesender'."""
1958
1959         if command:
1960             self.command = command[:]
1961         else:
1962             self.command = [self.find_sendmail(), '-oi', '-t']
1963
1964         if envelopesender:
1965             self.command.extend(['-f', envelopesender])
1966
1967     def send(self, lines, to_addrs):
1968         try:
1969             p = subprocess.Popen(self.command, stdin=subprocess.PIPE)
1970         except OSError:
1971             sys.stderr.write(
1972                 '*** Cannot execute command: %s\n' % ' '.join(self.command) +
1973                 '*** %s\n' % sys.exc_info()[1] +
1974                 '*** Try setting multimailhook.mailer to "smtp"\n' +
1975                 '*** to send emails without using the sendmail command.\n'
1976                 )
1977             sys.exit(1)
1978         try:
1979             lines = (str_to_bytes(line) for line in lines)
1980             p.stdin.writelines(lines)
1981         except Exception:
1982             sys.stderr.write(
1983                 '*** Error while generating commit email\n'
1984                 '***  - mail sending aborted.\n'
1985                 )
1986             try:
1987                 # subprocess.terminate() is not available in Python 2.4
1988                 p.terminate()
1989             except AttributeError:
1990                 pass
1991             raise
1992         else:
1993             p.stdin.close()
1994             retcode = p.wait()
1995             if retcode:
1996                 raise CommandError(self.command, retcode)
1997
1998
1999 class SMTPMailer(Mailer):
2000     """Send emails using Python's smtplib."""
2001
2002     def __init__(self, envelopesender, smtpserver,
2003                  smtpservertimeout=10.0, smtpserverdebuglevel=0,
2004                  smtpencryption='none',
2005                  smtpuser='', smtppass='',
2006                  smtpcacerts=''
2007                  ):
2008         if not envelopesender:
2009             sys.stderr.write(
2010                 'fatal: git_multimail: cannot use SMTPMailer without a sender address.\n'
2011                 'please set either multimailhook.envelopeSender or user.email\n'
2012                 )
2013             sys.exit(1)
2014         if smtpencryption == 'ssl' and not (smtpuser and smtppass):
2015             raise ConfigurationException(
2016                 'Cannot use SMTPMailer with security option ssl '
2017                 'without options username and password.'
2018                 )
2019         self.envelopesender = envelopesender
2020         self.smtpserver = smtpserver
2021         self.smtpservertimeout = smtpservertimeout
2022         self.smtpserverdebuglevel = smtpserverdebuglevel
2023         self.security = smtpencryption
2024         self.username = smtpuser
2025         self.password = smtppass
2026         self.smtpcacerts = smtpcacerts
2027         try:
2028             def call(klass, server, timeout):
2029                 try:
2030                     return klass(server, timeout=timeout)
2031                 except TypeError:
2032                     # Old Python versions do not have timeout= argument.
2033                     return klass(server)
2034             if self.security == 'none':
2035                 self.smtp = call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)
2036             elif self.security == 'ssl':
2037                 if self.smtpcacerts:
2038                     raise smtplib.SMTPException(
2039                         "Checking certificate is not supported for ssl, prefer starttls"
2040                         )
2041                 self.smtp = call(smtplib.SMTP_SSL, self.smtpserver, timeout=self.smtpservertimeout)
2042             elif self.security == 'tls':
2043                 if 'ssl' not in sys.modules:
2044                     sys.stderr.write(
2045                         '*** Your Python version does not have the ssl library installed\n'
2046                         '*** smtpEncryption=tls is not available.\n'
2047                         '*** Either upgrade Python to 2.6 or later\n'
2048                         '    or use git_multimail.py version 1.2.\n')
2049                 if ':' not in self.smtpserver:
2050                     self.smtpserver += ':587'  # default port for TLS
2051                 self.smtp = call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)
2052                 # start: ehlo + starttls
2053                 # equivalent to
2054                 #     self.smtp.ehlo()
2055                 #     self.smtp.starttls()
2056                 # with acces to the ssl layer
2057                 self.smtp.ehlo()
2058                 if not self.smtp.has_extn("starttls"):
2059                     raise smtplib.SMTPException("STARTTLS extension not supported by server")
2060                 resp, reply = self.smtp.docmd("STARTTLS")
2061                 if resp != 220:
2062                     raise smtplib.SMTPException("Wrong answer to the STARTTLS command")
2063                 if self.smtpcacerts:
2064                     self.smtp.sock = ssl.wrap_socket(
2065                         self.smtp.sock,
2066                         ca_certs=self.smtpcacerts,
2067                         cert_reqs=ssl.CERT_REQUIRED
2068                         )
2069                 else:
2070                     self.smtp.sock = ssl.wrap_socket(
2071                         self.smtp.sock,
2072                         cert_reqs=ssl.CERT_NONE
2073                         )
2074                     sys.stderr.write(
2075                         '*** Warning, the server certificat is not verified (smtp) ***\n'
2076                         '***          set the option smtpCACerts                   ***\n'
2077                         )
2078                 if not hasattr(self.smtp.sock, "read"):
2079                     # using httplib.FakeSocket with Python 2.5.x or earlier
2080                     self.smtp.sock.read = self.smtp.sock.recv
2081                 self.smtp.file = smtplib.SSLFakeFile(self.smtp.sock)
2082                 self.smtp.helo_resp = None
2083                 self.smtp.ehlo_resp = None
2084                 self.smtp.esmtp_features = {}
2085                 self.smtp.does_esmtp = 0
2086                 # end:   ehlo + starttls
2087                 self.smtp.ehlo()
2088             else:
2089                 sys.stdout.write('*** Error: Control reached an invalid option. ***')
2090                 sys.exit(1)
2091             if self.smtpserverdebuglevel > 0:
2092                 sys.stdout.write(
2093                     "*** Setting debug on for SMTP server connection (%s) ***\n"
2094                     % self.smtpserverdebuglevel)
2095                 self.smtp.set_debuglevel(self.smtpserverdebuglevel)
2096         except Exception:
2097             sys.stderr.write(
2098                 '*** Error establishing SMTP connection to %s ***\n'
2099                 % self.smtpserver)
2100             sys.stderr.write('*** %s\n' % sys.exc_info()[1])
2101             sys.exit(1)
2102
2103     def __del__(self):
2104         if hasattr(self, 'smtp'):
2105             self.smtp.quit()
2106             del self.smtp
2107
2108     def send(self, lines, to_addrs):
2109         try:
2110             if self.username or self.password:
2111                 self.smtp.login(self.username, self.password)
2112             msg = ''.join(lines)
2113             # turn comma-separated list into Python list if needed.
2114             if is_string(to_addrs):
2115                 to_addrs = [email for (name, email) in getaddresses([to_addrs])]
2116             self.smtp.sendmail(self.envelopesender, to_addrs, msg)
2117         except smtplib.SMTPResponseException:
2118             sys.stderr.write('*** Error sending email ***\n')
2119             err = sys.exc_info()[1]
2120             sys.stderr.write('*** Error %d: %s\n' % (err.smtp_code,
2121                                                      bytes_to_str(err.smtp_error)))
2122             try:
2123                 smtp = self.smtp
2124                 # delete the field before quit() so that in case of
2125                 # error, self.smtp is deleted anyway.
2126                 del self.smtp
2127                 smtp.quit()
2128             except:
2129                 sys.stderr.write('*** Error closing the SMTP connection ***\n')
2130                 sys.stderr.write('*** Exiting anyway ... ***\n')
2131                 sys.stderr.write('*** %s\n' % sys.exc_info()[1])
2132             sys.exit(1)
2133
2134
2135 class OutputMailer(Mailer):
2136     """Write emails to an output stream, bracketed by lines of '=' characters.
2137
2138     This is intended for debugging purposes."""
2139
2140     SEPARATOR = '=' * 75 + '\n'
2141
2142     def __init__(self, f):
2143         self.f = f
2144
2145     def send(self, lines, to_addrs):
2146         write_str(self.f, self.SEPARATOR)
2147         for line in lines:
2148             write_str(self.f, line)
2149         write_str(self.f, self.SEPARATOR)
2150
2151
2152 def get_git_dir():
2153     """Determine GIT_DIR.
2154
2155     Determine GIT_DIR either from the GIT_DIR environment variable or
2156     from the working directory, using Git's usual rules."""
2157
2158     try:
2159         return read_git_output(['rev-parse', '--git-dir'])
2160     except CommandError:
2161         sys.stderr.write('fatal: git_multimail: not in a git directory\n')
2162         sys.exit(1)
2163
2164
2165 class Environment(object):
2166     """Describes the environment in which the push is occurring.
2167
2168     An Environment object encapsulates information about the local
2169     environment.  For example, it knows how to determine:
2170
2171     * the name of the repository to which the push occurred
2172
2173     * what user did the push
2174
2175     * what users want to be informed about various types of changes.
2176
2177     An Environment object is expected to have the following methods:
2178
2179         get_repo_shortname()
2180
2181             Return a short name for the repository, for display
2182             purposes.
2183
2184         get_repo_path()
2185
2186             Return the absolute path to the Git repository.
2187
2188         get_emailprefix()
2189
2190             Return a string that will be prefixed to every email's
2191             subject.
2192
2193         get_pusher()
2194
2195             Return the username of the person who pushed the changes.
2196             This value is used in the email body to indicate who
2197             pushed the change.
2198
2199         get_pusher_email() (may return None)
2200
2201             Return the email address of the person who pushed the
2202             changes.  The value should be a single RFC 2822 email
2203             address as a string; e.g., "Joe User <user@example.com>"
2204             if available, otherwise "user@example.com".  If set, the
2205             value is used as the Reply-To address for refchange
2206             emails.  If it is impossible to determine the pusher's
2207             email, this attribute should be set to None (in which case
2208             no Reply-To header will be output).
2209
2210         get_sender()
2211
2212             Return the address to be used as the 'From' email address
2213             in the email envelope.
2214
2215         get_fromaddr(change=None)
2216
2217             Return the 'From' email address used in the email 'From:'
2218             headers.  If the change is known when this function is
2219             called, it is passed in as the 'change' parameter.  (May
2220             be a full RFC 2822 email address like 'Joe User
2221             <user@example.com>'.)
2222
2223         get_administrator()
2224
2225             Return the name and/or email of the repository
2226             administrator.  This value is used in the footer as the
2227             person to whom requests to be removed from the
2228             notification list should be sent.  Ideally, it should
2229             include a valid email address.
2230
2231         get_reply_to_refchange()
2232         get_reply_to_commit()
2233
2234             Return the address to use in the email "Reply-To" header,
2235             as a string.  These can be an RFC 2822 email address, or
2236             None to omit the "Reply-To" header.
2237             get_reply_to_refchange() is used for refchange emails;
2238             get_reply_to_commit() is used for individual commit
2239             emails.
2240
2241         get_ref_filter_regex()
2242
2243             Return a tuple -- a compiled regex, and a boolean indicating
2244             whether the regex picks refs to include (if False, the regex
2245             matches on refs to exclude).
2246
2247         get_default_ref_ignore_regex()
2248
2249             Return a regex that should be ignored for both what emails
2250             to send and when computing what commits are considered new
2251             to the repository.  Default is "^refs/notes/".
2252
2253     They should also define the following attributes:
2254
2255         announce_show_shortlog (bool)
2256
2257             True iff announce emails should include a shortlog.
2258
2259         commit_email_format (string)
2260
2261             If "html", generate commit emails in HTML instead of plain text
2262             used by default.
2263
2264         html_in_intro (bool)
2265         html_in_footer (bool)
2266
2267             When generating HTML emails, the introduction (respectively,
2268             the footer) will be HTML-escaped iff html_in_intro (respectively,
2269             the footer) is true. When false, only the values used to expand
2270             the template are escaped.
2271
2272         refchange_showgraph (bool)
2273
2274             True iff refchanges emails should include a detailed graph.
2275
2276         refchange_showlog (bool)
2277
2278             True iff refchanges emails should include a detailed log.
2279
2280         diffopts (list of strings)
2281
2282             The options that should be passed to 'git diff' for the
2283             summary email.  The value should be a list of strings
2284             representing words to be passed to the command.
2285
2286         graphopts (list of strings)
2287
2288             Analogous to diffopts, but contains options passed to
2289             'git log --graph' when generating the detailed graph for
2290             a set of commits (see refchange_showgraph)
2291
2292         logopts (list of strings)
2293
2294             Analogous to diffopts, but contains options passed to
2295             'git log' when generating the detailed log for a set of
2296             commits (see refchange_showlog)
2297
2298         commitlogopts (list of strings)
2299
2300             The options that should be passed to 'git log' for each
2301             commit mail.  The value should be a list of strings
2302             representing words to be passed to the command.
2303
2304         date_substitute (string)
2305
2306             String to be used in substitution for 'Date:' at start of
2307             line in the output of 'git log'.
2308
2309         quiet (bool)
2310             On success do not write to stderr
2311
2312         stdout (bool)
2313             Write email to stdout rather than emailing. Useful for debugging
2314
2315         combine_when_single_commit (bool)
2316
2317             True if a combined email should be produced when a single
2318             new commit is pushed to a branch, False otherwise.
2319
2320         from_refchange, from_commit (strings)
2321
2322             Addresses to use for the From: field for refchange emails
2323             and commit emails respectively.  Set from
2324             multimailhook.fromRefchange and multimailhook.fromCommit
2325             by ConfigEnvironmentMixin.
2326
2327     """
2328
2329     REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$')
2330
2331     def __init__(self, osenv=None):
2332         self.osenv = osenv or os.environ
2333         self.announce_show_shortlog = False
2334         self.commit_email_format = "text"
2335         self.html_in_intro = False
2336         self.html_in_footer = False
2337         self.commitBrowseURL = None
2338         self.maxcommitemails = 500
2339         self.diffopts = ['--stat', '--summary', '--find-copies-harder']
2340         self.graphopts = ['--oneline', '--decorate']
2341         self.logopts = []
2342         self.refchange_showgraph = False
2343         self.refchange_showlog = False
2344         self.commitlogopts = ['-C', '--stat', '-p', '--cc']
2345         self.date_substitute = 'AuthorDate: '
2346         self.quiet = False
2347         self.stdout = False
2348         self.combine_when_single_commit = True
2349
2350         self.COMPUTED_KEYS = [
2351             'administrator',
2352             'charset',
2353             'emailprefix',
2354             'pusher',
2355             'pusher_email',
2356             'repo_path',
2357             'repo_shortname',
2358             'sender',
2359             ]
2360
2361         self._values = None
2362
2363     def get_repo_shortname(self):
2364         """Use the last part of the repo path, with ".git" stripped off if present."""
2365
2366         basename = os.path.basename(os.path.abspath(self.get_repo_path()))
2367         m = self.REPO_NAME_RE.match(basename)
2368         if m:
2369             return m.group('name')
2370         else:
2371             return basename
2372
2373     def get_pusher(self):
2374         raise NotImplementedError()
2375
2376     def get_pusher_email(self):
2377         return None
2378
2379     def get_fromaddr(self, change=None):
2380         config = Config('user')
2381         fromname = config.get('name', default='')
2382         fromemail = config.get('email', default='')
2383         if fromemail:
2384             return formataddr([fromname, fromemail])
2385         return self.get_sender()
2386
2387     def get_administrator(self):
2388         return 'the administrator of this repository'
2389
2390     def get_emailprefix(self):
2391         return ''
2392
2393     def get_repo_path(self):
2394         if read_git_output(['rev-parse', '--is-bare-repository']) == 'true':
2395             path = get_git_dir()
2396         else:
2397             path = read_git_output(['rev-parse', '--show-toplevel'])
2398         return os.path.abspath(path)
2399
2400     def get_charset(self):
2401         return CHARSET
2402
2403     def get_values(self):
2404         """Return a dictionary {keyword: expansion} for this Environment.
2405
2406         This method is called by Change._compute_values().  The keys
2407         in the returned dictionary are available to be used in any of
2408         the templates.  The dictionary is created by calling
2409         self.get_NAME() for each of the attributes named in
2410         COMPUTED_KEYS and recording those that do not return None.
2411         The return value is always a new dictionary."""
2412
2413         if self._values is None:
2414             values = {'': ''}  # %()s expands to the empty string.
2415
2416             for key in self.COMPUTED_KEYS:
2417                 value = getattr(self, 'get_%s' % (key,))()
2418                 if value is not None:
2419                     values[key] = value
2420
2421             self._values = values
2422
2423         return self._values.copy()
2424
2425     def get_refchange_recipients(self, refchange):
2426         """Return the recipients for notifications about refchange.
2427
2428         Return the list of email addresses to which notifications
2429         about the specified ReferenceChange should be sent."""
2430
2431         raise NotImplementedError()
2432
2433     def get_announce_recipients(self, annotated_tag_change):
2434         """Return the recipients for notifications about annotated_tag_change.
2435
2436         Return the list of email addresses to which notifications
2437         about the specified AnnotatedTagChange should be sent."""
2438
2439         raise NotImplementedError()
2440
2441     def get_reply_to_refchange(self, refchange):
2442         return self.get_pusher_email()
2443
2444     def get_revision_recipients(self, revision):
2445         """Return the recipients for messages about revision.
2446
2447         Return the list of email addresses to which notifications
2448         about the specified Revision should be sent.  This method
2449         could be overridden, for example, to take into account the
2450         contents of the revision when deciding whom to notify about
2451         it.  For example, there could be a scheme for users to express
2452         interest in particular files or subdirectories, and only
2453         receive notification emails for revisions that affecting those
2454         files."""
2455
2456         raise NotImplementedError()
2457
2458     def get_reply_to_commit(self, revision):
2459         return revision.author
2460
2461     def get_default_ref_ignore_regex(self):
2462         # The commit messages of git notes are essentially meaningless
2463         # and "filenames" in git notes commits are an implementational
2464         # detail that might surprise users at first.  As such, we
2465         # would need a completely different method for handling emails
2466         # of git notes in order for them to be of benefit for users,
2467         # which we simply do not have right now.
2468         return "^refs/notes/"
2469
2470     def filter_body(self, lines):
2471         """Filter the lines intended for an email body.
2472
2473         lines is an iterable over the lines that would go into the
2474         email body.  Filter it (e.g., limit the number of lines, the
2475         line length, character set, etc.), returning another iterable.
2476         See FilterLinesEnvironmentMixin and MaxlinesEnvironmentMixin
2477         for classes implementing this functionality."""
2478
2479         return lines
2480
2481     def log_msg(self, msg):
2482         """Write the string msg on a log file or on stderr.
2483
2484         Sends the text to stderr by default, override to change the behavior."""
2485         write_str(sys.stderr, msg)
2486
2487     def log_warning(self, msg):
2488         """Write the string msg on a log file or on stderr.
2489
2490         Sends the text to stderr by default, override to change the behavior."""
2491         write_str(sys.stderr, msg)
2492
2493     def log_error(self, msg):
2494         """Write the string msg on a log file or on stderr.
2495
2496         Sends the text to stderr by default, override to change the behavior."""
2497         write_str(sys.stderr, msg)
2498
2499
2500 class ConfigEnvironmentMixin(Environment):
2501     """A mixin that sets self.config to its constructor's config argument.
2502
2503     This class's constructor consumes the "config" argument.
2504
2505     Mixins that need to inspect the config should inherit from this
2506     class (1) to make sure that "config" is still in the constructor
2507     arguments with its own constructor runs and/or (2) to be sure that
2508     self.config is set after construction."""
2509
2510     def __init__(self, config, **kw):
2511         super(ConfigEnvironmentMixin, self).__init__(**kw)
2512         self.config = config
2513
2514
2515 class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):
2516     """An Environment that reads most of its information from "git config"."""
2517
2518     @staticmethod
2519     def forbid_field_values(name, value, forbidden):
2520         for forbidden_val in forbidden:
2521             if value is not None and value.lower() == forbidden:
2522                 raise ConfigurationException(
2523                     '"%s" is not an allowed setting for %s' % (value, name)
2524                     )
2525
2526     def __init__(self, config, **kw):
2527         super(ConfigOptionsEnvironmentMixin, self).__init__(
2528             config=config, **kw
2529             )
2530
2531         for var, cfg in (
2532                 ('announce_show_shortlog', 'announceshortlog'),
2533                 ('refchange_showgraph', 'refchangeShowGraph'),
2534                 ('refchange_showlog', 'refchangeshowlog'),
2535                 ('quiet', 'quiet'),
2536                 ('stdout', 'stdout'),
2537                 ):
2538             val = config.get_bool(cfg)
2539             if val is not None:
2540                 setattr(self, var, val)
2541
2542         commit_email_format = config.get('commitEmailFormat')
2543         if commit_email_format is not None:
2544             if commit_email_format != "html" and commit_email_format != "text":
2545                 self.log_warning(
2546                     '*** Unknown value for multimailhook.commitEmailFormat: %s\n' %
2547                     commit_email_format +
2548                     '*** Expected either "text" or "html".  Ignoring.\n'
2549                     )
2550             else:
2551                 self.commit_email_format = commit_email_format
2552
2553         html_in_intro = config.get_bool('htmlInIntro')
2554         if html_in_intro is not None:
2555             self.html_in_intro = html_in_intro
2556
2557         html_in_footer = config.get_bool('htmlInFooter')
2558         if html_in_footer is not None:
2559             self.html_in_footer = html_in_footer
2560
2561         self.commitBrowseURL = config.get('commitBrowseURL')
2562
2563         maxcommitemails = config.get('maxcommitemails')
2564         if maxcommitemails is not None:
2565             try:
2566                 self.maxcommitemails = int(maxcommitemails)
2567             except ValueError:
2568                 self.log_warning(
2569                     '*** Malformed value for multimailhook.maxCommitEmails: %s\n'
2570                     % maxcommitemails +
2571                     '*** Expected a number.  Ignoring.\n'
2572                     )
2573
2574         diffopts = config.get('diffopts')
2575         if diffopts is not None:
2576             self.diffopts = shlex.split(diffopts)
2577
2578         graphopts = config.get('graphOpts')
2579         if graphopts is not None:
2580             self.graphopts = shlex.split(graphopts)
2581
2582         logopts = config.get('logopts')
2583         if logopts is not None:
2584             self.logopts = shlex.split(logopts)
2585
2586         commitlogopts = config.get('commitlogopts')
2587         if commitlogopts is not None:
2588             self.commitlogopts = shlex.split(commitlogopts)
2589
2590         date_substitute = config.get('dateSubstitute')
2591         if date_substitute == 'none':
2592             self.date_substitute = None
2593         elif date_substitute is not None:
2594             self.date_substitute = date_substitute
2595
2596         reply_to = config.get('replyTo')
2597         self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to)
2598         self.forbid_field_values('replyToRefchange',
2599                                  self.__reply_to_refchange,
2600                                  ['author'])
2601         self.__reply_to_commit = config.get('replyToCommit', default=reply_to)
2602
2603         self.from_refchange = config.get('fromRefchange')
2604         self.forbid_field_values('fromRefchange',
2605                                  self.from_refchange,
2606                                  ['author', 'none'])
2607         self.from_commit = config.get('fromCommit')
2608         self.forbid_field_values('fromCommit',
2609                                  self.from_commit,
2610                                  ['none'])
2611
2612         combine = config.get_bool('combineWhenSingleCommit')
2613         if combine is not None:
2614             self.combine_when_single_commit = combine
2615
2616     def get_administrator(self):
2617         return (
2618             self.config.get('administrator') or
2619             self.get_sender() or
2620             super(ConfigOptionsEnvironmentMixin, self).get_administrator()
2621             )
2622
2623     def get_repo_shortname(self):
2624         return (
2625             self.config.get('reponame') or
2626             super(ConfigOptionsEnvironmentMixin, self).get_repo_shortname()
2627             )
2628
2629     def get_emailprefix(self):
2630         emailprefix = self.config.get('emailprefix')
2631         if emailprefix is not None:
2632             emailprefix = emailprefix.strip()
2633             if emailprefix:
2634                 return emailprefix + ' '
2635             else:
2636                 return ''
2637         else:
2638             return '[%s] ' % (self.get_repo_shortname(),)
2639
2640     def get_sender(self):
2641         return self.config.get('envelopesender')
2642
2643     def process_addr(self, addr, change):
2644         if addr.lower() == 'author':
2645             if hasattr(change, 'author'):
2646                 return change.author
2647             else:
2648                 return None
2649         elif addr.lower() == 'pusher':
2650             return self.get_pusher_email()
2651         elif addr.lower() == 'none':
2652             return None
2653         else:
2654             return addr
2655
2656     def get_fromaddr(self, change=None):
2657         fromaddr = self.config.get('from')
2658         if change:
2659             alt_fromaddr = change.get_alt_fromaddr()
2660             if alt_fromaddr:
2661                 fromaddr = alt_fromaddr
2662         if fromaddr:
2663             fromaddr = self.process_addr(fromaddr, change)
2664         if fromaddr:
2665             return fromaddr
2666         return super(ConfigOptionsEnvironmentMixin, self).get_fromaddr(change)
2667
2668     def get_reply_to_refchange(self, refchange):
2669         if self.__reply_to_refchange is None:
2670             return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_refchange(refchange)
2671         else:
2672             return self.process_addr(self.__reply_to_refchange, refchange)
2673
2674     def get_reply_to_commit(self, revision):
2675         if self.__reply_to_commit is None:
2676             return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_commit(revision)
2677         else:
2678             return self.process_addr(self.__reply_to_commit, revision)
2679
2680     def get_scancommitforcc(self):
2681         return self.config.get('scancommitforcc')
2682
2683
2684 class FilterLinesEnvironmentMixin(Environment):
2685     """Handle encoding and maximum line length of body lines.
2686
2687         emailmaxlinelength (int or None)
2688
2689             The maximum length of any single line in the email body.
2690             Longer lines are truncated at that length with ' [...]'
2691             appended.
2692
2693         strict_utf8 (bool)
2694
2695             If this field is set to True, then the email body text is
2696             expected to be UTF-8.  Any invalid characters are
2697             converted to U+FFFD, the Unicode replacement character
2698             (encoded as UTF-8, of course).
2699
2700     """
2701
2702     def __init__(self, strict_utf8=True, emailmaxlinelength=500, **kw):
2703         super(FilterLinesEnvironmentMixin, self).__init__(**kw)
2704         self.__strict_utf8 = strict_utf8
2705         self.__emailmaxlinelength = emailmaxlinelength
2706
2707     def filter_body(self, lines):
2708         lines = super(FilterLinesEnvironmentMixin, self).filter_body(lines)
2709         if self.__strict_utf8:
2710             if not PYTHON3:
2711                 lines = (line.decode(ENCODING, 'replace') for line in lines)
2712             # Limit the line length in Unicode-space to avoid
2713             # splitting characters:
2714             if self.__emailmaxlinelength:
2715                 lines = limit_linelength(lines, self.__emailmaxlinelength)
2716             if not PYTHON3:
2717                 lines = (line.encode(ENCODING, 'replace') for line in lines)
2718         elif self.__emailmaxlinelength:
2719             lines = limit_linelength(lines, self.__emailmaxlinelength)
2720
2721         return lines
2722
2723
2724 class ConfigFilterLinesEnvironmentMixin(
2725         ConfigEnvironmentMixin,
2726         FilterLinesEnvironmentMixin,
2727         ):
2728     """Handle encoding and maximum line length based on config."""
2729
2730     def __init__(self, config, **kw):
2731         strict_utf8 = config.get_bool('emailstrictutf8', default=None)
2732         if strict_utf8 is not None:
2733             kw['strict_utf8'] = strict_utf8
2734
2735         emailmaxlinelength = config.get('emailmaxlinelength')
2736         if emailmaxlinelength is not None:
2737             kw['emailmaxlinelength'] = int(emailmaxlinelength)
2738
2739         super(ConfigFilterLinesEnvironmentMixin, self).__init__(
2740             config=config, **kw
2741             )
2742
2743
2744 class MaxlinesEnvironmentMixin(Environment):
2745     """Limit the email body to a specified number of lines."""
2746
2747     def __init__(self, emailmaxlines, **kw):
2748         super(MaxlinesEnvironmentMixin, self).__init__(**kw)
2749         self.__emailmaxlines = emailmaxlines
2750
2751     def filter_body(self, lines):
2752         lines = super(MaxlinesEnvironmentMixin, self).filter_body(lines)
2753         if self.__emailmaxlines:
2754             lines = limit_lines(lines, self.__emailmaxlines)
2755         return lines
2756
2757
2758 class ConfigMaxlinesEnvironmentMixin(
2759         ConfigEnvironmentMixin,
2760         MaxlinesEnvironmentMixin,
2761         ):
2762     """Limit the email body to the number of lines specified in config."""
2763
2764     def __init__(self, config, **kw):
2765         emailmaxlines = int(config.get('emailmaxlines', default='0'))
2766         super(ConfigMaxlinesEnvironmentMixin, self).__init__(
2767             config=config,
2768             emailmaxlines=emailmaxlines,
2769             **kw
2770             )
2771
2772
2773 class FQDNEnvironmentMixin(Environment):
2774     """A mixin that sets the host's FQDN to its constructor argument."""
2775
2776     def __init__(self, fqdn, **kw):
2777         super(FQDNEnvironmentMixin, self).__init__(**kw)
2778         self.COMPUTED_KEYS += ['fqdn']
2779         self.__fqdn = fqdn
2780
2781     def get_fqdn(self):
2782         """Return the fully-qualified domain name for this host.
2783
2784         Return None if it is unavailable or unwanted."""
2785
2786         return self.__fqdn
2787
2788
2789 class ConfigFQDNEnvironmentMixin(
2790         ConfigEnvironmentMixin,
2791         FQDNEnvironmentMixin,
2792         ):
2793     """Read the FQDN from the config."""
2794
2795     def __init__(self, config, **kw):
2796         fqdn = config.get('fqdn')
2797         super(ConfigFQDNEnvironmentMixin, self).__init__(
2798             config=config,
2799             fqdn=fqdn,
2800             **kw
2801             )
2802
2803
2804 class ComputeFQDNEnvironmentMixin(FQDNEnvironmentMixin):
2805     """Get the FQDN by calling socket.getfqdn()."""
2806
2807     def __init__(self, **kw):
2808         super(ComputeFQDNEnvironmentMixin, self).__init__(
2809             fqdn=socket.getfqdn(),
2810             **kw
2811             )
2812
2813
2814 class PusherDomainEnvironmentMixin(ConfigEnvironmentMixin):
2815     """Deduce pusher_email from pusher by appending an emaildomain."""
2816
2817     def __init__(self, **kw):
2818         super(PusherDomainEnvironmentMixin, self).__init__(**kw)
2819         self.__emaildomain = self.config.get('emaildomain')
2820
2821     def get_pusher_email(self):
2822         if self.__emaildomain:
2823             # Derive the pusher's full email address in the default way:
2824             return '%s@%s' % (self.get_pusher(), self.__emaildomain)
2825         else:
2826             return super(PusherDomainEnvironmentMixin, self).get_pusher_email()
2827
2828
2829 class StaticRecipientsEnvironmentMixin(Environment):
2830     """Set recipients statically based on constructor parameters."""
2831
2832     def __init__(
2833             self,
2834             refchange_recipients, announce_recipients, revision_recipients, scancommitforcc,
2835             **kw
2836             ):
2837         super(StaticRecipientsEnvironmentMixin, self).__init__(**kw)
2838
2839         # The recipients for various types of notification emails, as
2840         # RFC 2822 email addresses separated by commas (or the empty
2841         # string if no recipients are configured).  Although there is
2842         # a mechanism to choose the recipient lists based on on the
2843         # actual *contents* of the change being reported, we only
2844         # choose based on the *type* of the change.  Therefore we can
2845         # compute them once and for all:
2846         if not (refchange_recipients or
2847                 announce_recipients or
2848                 revision_recipients or
2849                 scancommitforcc):
2850             raise ConfigurationException('No email recipients configured!')
2851         self.__refchange_recipients = refchange_recipients
2852         self.__announce_recipients = announce_recipients
2853         self.__revision_recipients = revision_recipients
2854
2855     def get_refchange_recipients(self, refchange):
2856         return self.__refchange_recipients
2857
2858     def get_announce_recipients(self, annotated_tag_change):
2859         return self.__announce_recipients
2860
2861     def get_revision_recipients(self, revision):
2862         return self.__revision_recipients
2863
2864
2865 class ConfigRecipientsEnvironmentMixin(
2866         ConfigEnvironmentMixin,
2867         StaticRecipientsEnvironmentMixin
2868         ):
2869     """Determine recipients statically based on config."""
2870
2871     def __init__(self, config, **kw):
2872         super(ConfigRecipientsEnvironmentMixin, self).__init__(
2873             config=config,
2874             refchange_recipients=self._get_recipients(
2875                 config, 'refchangelist', 'mailinglist',
2876                 ),
2877             announce_recipients=self._get_recipients(
2878                 config, 'announcelist', 'refchangelist', 'mailinglist',
2879                 ),
2880             revision_recipients=self._get_recipients(
2881                 config, 'commitlist', 'mailinglist',
2882                 ),
2883             scancommitforcc=config.get('scancommitforcc'),
2884             **kw
2885             )
2886
2887     def _get_recipients(self, config, *names):
2888         """Return the recipients for a particular type of message.
2889
2890         Return the list of email addresses to which a particular type
2891         of notification email should be sent, by looking at the config
2892         value for "multimailhook.$name" for each of names.  Use the
2893         value from the first name that is configured.  The return
2894         value is a (possibly empty) string containing RFC 2822 email
2895         addresses separated by commas.  If no configuration could be
2896         found, raise a ConfigurationException."""
2897
2898         for name in names:
2899             lines = config.get_all(name)
2900             if lines is not None:
2901                 lines = [line.strip() for line in lines]
2902                 # Single "none" is a special value equivalen to empty string.
2903                 if lines == ['none']:
2904                     lines = ['']
2905                 return ', '.join(lines)
2906         else:
2907             return ''
2908
2909
2910 class StaticRefFilterEnvironmentMixin(Environment):
2911     """Set branch filter statically based on constructor parameters."""
2912
2913     def __init__(self, ref_filter_incl_regex, ref_filter_excl_regex,
2914                  ref_filter_do_send_regex, ref_filter_dont_send_regex,
2915                  **kw):
2916         super(StaticRefFilterEnvironmentMixin, self).__init__(**kw)
2917
2918         if ref_filter_incl_regex and ref_filter_excl_regex:
2919             raise ConfigurationException(
2920                 "Cannot specify both a ref inclusion and exclusion regex.")
2921         self.__is_inclusion_filter = bool(ref_filter_incl_regex)
2922         default_exclude = self.get_default_ref_ignore_regex()
2923         if ref_filter_incl_regex:
2924             ref_filter_regex = ref_filter_incl_regex
2925         elif ref_filter_excl_regex:
2926             ref_filter_regex = ref_filter_excl_regex + '|' + default_exclude
2927         else:
2928             ref_filter_regex = default_exclude
2929         try:
2930             self.__compiled_regex = re.compile(ref_filter_regex)
2931         except Exception:
2932             raise ConfigurationException(
2933                 'Invalid Ref Filter Regex "%s": %s' % (ref_filter_regex, sys.exc_info()[1]))
2934
2935         if ref_filter_do_send_regex and ref_filter_dont_send_regex:
2936             raise ConfigurationException(
2937                 "Cannot specify both a ref doSend and dontSend regex.")
2938         if ref_filter_do_send_regex or ref_filter_dont_send_regex:
2939             self.__is_do_send_filter = bool(ref_filter_do_send_regex)
2940             if ref_filter_incl_regex:
2941                 ref_filter_send_regex = ref_filter_incl_regex
2942             elif ref_filter_excl_regex:
2943                 ref_filter_send_regex = ref_filter_excl_regex
2944             else:
2945                 ref_filter_send_regex = '.*'
2946                 self.__is_do_send_filter = True
2947             try:
2948                 self.__send_compiled_regex = re.compile(ref_filter_send_regex)
2949             except Exception:
2950                 raise ConfigurationException(
2951                     'Invalid Ref Filter Regex "%s": %s' %
2952                     (ref_filter_send_regex, sys.exc_info()[1]))
2953         else:
2954             self.__send_compiled_regex = self.__compiled_regex
2955             self.__is_do_send_filter = self.__is_inclusion_filter
2956
2957     def get_ref_filter_regex(self, send_filter=False):
2958         if send_filter:
2959             return self.__send_compiled_regex, self.__is_do_send_filter
2960         else:
2961             return self.__compiled_regex, self.__is_inclusion_filter
2962
2963
2964 class ConfigRefFilterEnvironmentMixin(
2965         ConfigEnvironmentMixin,
2966         StaticRefFilterEnvironmentMixin
2967         ):
2968     """Determine branch filtering statically based on config."""
2969
2970     def _get_regex(self, config, key):
2971         """Get a list of whitespace-separated regex. The refFilter* config
2972         variables are multivalued (hence the use of get_all), and we
2973         allow each entry to be a whitespace-separated list (hence the
2974         split on each line). The whole thing is glued into a single regex."""
2975         values = config.get_all(key)
2976         if values is None:
2977             return values
2978         items = []
2979         for line in values:
2980             for i in line.split():
2981                 items.append(i)
2982         if items == []:
2983             return None
2984         return '|'.join(items)
2985
2986     def __init__(self, config, **kw):
2987         super(ConfigRefFilterEnvironmentMixin, self).__init__(
2988             config=config,
2989             ref_filter_incl_regex=self._get_regex(config, 'refFilterInclusionRegex'),
2990             ref_filter_excl_regex=self._get_regex(config, 'refFilterExclusionRegex'),
2991             ref_filter_do_send_regex=self._get_regex(config, 'refFilterDoSendRegex'),
2992             ref_filter_dont_send_regex=self._get_regex(config, 'refFilterDontSendRegex'),
2993             **kw
2994             )
2995
2996
2997 class ProjectdescEnvironmentMixin(Environment):
2998     """Make a "projectdesc" value available for templates.
2999
3000     By default, it is set to the first line of $GIT_DIR/description
3001     (if that file is present and appears to be set meaningfully)."""
3002
3003     def __init__(self, **kw):
3004         super(ProjectdescEnvironmentMixin, self).__init__(**kw)
3005         self.COMPUTED_KEYS += ['projectdesc']
3006
3007     def get_projectdesc(self):
3008         """Return a one-line descripition of the project."""
3009
3010         git_dir = get_git_dir()
3011         try:
3012             projectdesc = open(os.path.join(git_dir, 'description')).readline().strip()
3013             if projectdesc and not projectdesc.startswith('Unnamed repository'):
3014                 return projectdesc
3015         except IOError:
3016             pass
3017
3018         return 'UNNAMED PROJECT'
3019
3020
3021 class GenericEnvironmentMixin(Environment):
3022     def get_pusher(self):
3023         return self.osenv.get('USER', self.osenv.get('USERNAME', 'unknown user'))
3024
3025
3026 class GenericEnvironment(
3027         ProjectdescEnvironmentMixin,
3028         ConfigMaxlinesEnvironmentMixin,
3029         ComputeFQDNEnvironmentMixin,
3030         ConfigFilterLinesEnvironmentMixin,
3031         ConfigRecipientsEnvironmentMixin,
3032         ConfigRefFilterEnvironmentMixin,
3033         PusherDomainEnvironmentMixin,
3034         ConfigOptionsEnvironmentMixin,
3035         GenericEnvironmentMixin,
3036         Environment,
3037         ):
3038     pass
3039
3040
3041 class GitoliteEnvironmentMixin(Environment):
3042     def get_repo_shortname(self):
3043         # The gitolite environment variable $GL_REPO is a pretty good
3044         # repo_shortname (though it's probably not as good as a value
3045         # the user might have explicitly put in his config).
3046         return (
3047             self.osenv.get('GL_REPO', None) or
3048             super(GitoliteEnvironmentMixin, self).get_repo_shortname()
3049             )
3050
3051     def get_pusher(self):
3052         return self.osenv.get('GL_USER', 'unknown user')
3053
3054     def get_fromaddr(self, change=None):
3055         GL_USER = self.osenv.get('GL_USER')
3056         if GL_USER is not None:
3057             # Find the path to gitolite.conf.  Note that gitolite v3
3058             # did away with the GL_ADMINDIR and GL_CONF environment
3059             # variables (they are now hard-coded).
3060             GL_ADMINDIR = self.osenv.get(
3061                 'GL_ADMINDIR',
3062                 os.path.expanduser(os.path.join('~', '.gitolite')))
3063             GL_CONF = self.osenv.get(
3064                 'GL_CONF',
3065                 os.path.join(GL_ADMINDIR, 'conf', 'gitolite.conf'))
3066             if os.path.isfile(GL_CONF):
3067                 f = open(GL_CONF, 'rU')
3068                 try:
3069                     in_user_emails_section = False
3070                     re_template = r'^\s*#\s*%s\s*$'
3071                     re_begin, re_user, re_end = (
3072                         re.compile(re_template % x)
3073                         for x in (
3074                             r'BEGIN\s+USER\s+EMAILS',
3075                             re.escape(GL_USER) + r'\s+(.*)',
3076                             r'END\s+USER\s+EMAILS',
3077                             ))
3078                     for l in f:
3079                         l = l.rstrip('\n')
3080                         if not in_user_emails_section:
3081                             if re_begin.match(l):
3082                                 in_user_emails_section = True
3083                             continue
3084                         if re_end.match(l):
3085                             break
3086                         m = re_user.match(l)
3087                         if m:
3088                             return m.group(1)
3089                 finally:
3090                     f.close()
3091         return super(GitoliteEnvironmentMixin, self).get_fromaddr(change)
3092
3093
3094 class IncrementalDateTime(object):
3095     """Simple wrapper to give incremental date/times.
3096
3097     Each call will result in a date/time a second later than the
3098     previous call.  This can be used to falsify email headers, to
3099     increase the likelihood that email clients sort the emails
3100     correctly."""
3101
3102     def __init__(self):
3103         self.time = time.time()
3104         self.next = self.__next__  # Python 2 backward compatibility
3105
3106     def __next__(self):
3107         formatted = formatdate(self.time, True)
3108         self.time += 1
3109         return formatted
3110
3111
3112 class GitoliteEnvironment(
3113         ProjectdescEnvironmentMixin,
3114         ConfigMaxlinesEnvironmentMixin,
3115         ComputeFQDNEnvironmentMixin,
3116         ConfigFilterLinesEnvironmentMixin,
3117         ConfigRecipientsEnvironmentMixin,
3118         ConfigRefFilterEnvironmentMixin,
3119         PusherDomainEnvironmentMixin,
3120         ConfigOptionsEnvironmentMixin,
3121         GitoliteEnvironmentMixin,
3122         Environment,
3123         ):
3124     pass
3125
3126
3127 class StashEnvironmentMixin(Environment):
3128     def __init__(self, user=None, repo=None, **kw):
3129         super(StashEnvironmentMixin, self).__init__(**kw)
3130         self.__user = user
3131         self.__repo = repo
3132
3133     def get_repo_shortname(self):
3134         return self.__repo
3135
3136     def get_pusher(self):
3137         return re.match('(.*?)\s*<', self.__user).group(1)
3138
3139     def get_pusher_email(self):
3140         return self.__user
3141
3142     def get_fromaddr(self, change=None):
3143         return self.__user
3144
3145
3146 class StashEnvironment(
3147         StashEnvironmentMixin,
3148         ProjectdescEnvironmentMixin,
3149         ConfigMaxlinesEnvironmentMixin,
3150         ComputeFQDNEnvironmentMixin,
3151         ConfigFilterLinesEnvironmentMixin,
3152         ConfigRecipientsEnvironmentMixin,
3153         ConfigRefFilterEnvironmentMixin,
3154         PusherDomainEnvironmentMixin,
3155         ConfigOptionsEnvironmentMixin,
3156         Environment,
3157         ):
3158     pass
3159
3160
3161 class GerritEnvironmentMixin(Environment):
3162     def __init__(self, project=None, submitter=None, update_method=None, **kw):
3163         super(GerritEnvironmentMixin, self).__init__(**kw)
3164         self.__project = project
3165         self.__submitter = submitter
3166         self.__update_method = update_method
3167         "Make an 'update_method' value available for templates."
3168         self.COMPUTED_KEYS += ['update_method']
3169
3170     def get_repo_shortname(self):
3171         return self.__project
3172
3173     def get_pusher(self):
3174         if self.__submitter:
3175             if self.__submitter.find('<') != -1:
3176                 # Submitter has a configured email, we transformed
3177                 # __submitter into an RFC 2822 string already.
3178                 return re.match('(.*?)\s*<', self.__submitter).group(1)
3179             else:
3180                 # Submitter has no configured email, it's just his name.
3181                 return self.__submitter
3182         else:
3183             # If we arrive here, this means someone pushed "Submit" from
3184             # the gerrit web UI for the CR (or used one of the programmatic
3185             # APIs to do the same, such as gerrit review) and the
3186             # merge/push was done by the Gerrit user.  It was technically
3187             # triggered by someone else, but sadly we have no way of
3188             # determining who that someone else is at this point.
3189             return 'Gerrit'  # 'unknown user'?
3190
3191     def get_pusher_email(self):
3192         if self.__submitter:
3193             return self.__submitter
3194         else:
3195             return super(GerritEnvironmentMixin, self).get_pusher_email()
3196
3197     def get_fromaddr(self, change=None):
3198         if self.__submitter and self.__submitter.find('<') != -1:
3199             return self.__submitter
3200         else:
3201             return super(GerritEnvironmentMixin, self).get_fromaddr(change)
3202
3203     def get_default_ref_ignore_regex(self):
3204         default = super(GerritEnvironmentMixin, self).get_default_ref_ignore_regex()
3205         return default + '|^refs/changes/|^refs/cache-automerge/|^refs/meta/'
3206
3207     def get_revision_recipients(self, revision):
3208         # Merge commits created by Gerrit when users hit "Submit this patchset"
3209         # in the Web UI (or do equivalently with REST APIs or the gerrit review
3210         # command) are not something users want to see an individual email for.
3211         # Filter them out.
3212         committer = read_git_output(['log', '--no-walk', '--format=%cN',
3213                                      revision.rev.sha1])
3214         if committer == 'Gerrit Code Review':
3215             return []
3216         else:
3217             return super(GerritEnvironmentMixin, self).get_revision_recipients(revision)
3218
3219     def get_update_method(self):
3220         return self.__update_method
3221
3222
3223 class GerritEnvironment(
3224         GerritEnvironmentMixin,
3225         ProjectdescEnvironmentMixin,
3226         ConfigMaxlinesEnvironmentMixin,
3227         ComputeFQDNEnvironmentMixin,
3228         ConfigFilterLinesEnvironmentMixin,
3229         ConfigRecipientsEnvironmentMixin,
3230         ConfigRefFilterEnvironmentMixin,
3231         PusherDomainEnvironmentMixin,
3232         ConfigOptionsEnvironmentMixin,
3233         Environment,
3234         ):
3235     pass
3236
3237
3238 class Push(object):
3239     """Represent an entire push (i.e., a group of ReferenceChanges).
3240
3241     It is easy to figure out what commits were added to a *branch* by
3242     a Reference change:
3243
3244         git rev-list change.old..change.new
3245
3246     or removed from a *branch*:
3247
3248         git rev-list change.new..change.old
3249
3250     But it is not quite so trivial to determine which entirely new
3251     commits were added to the *repository* by a push and which old
3252     commits were discarded by a push.  A big part of the job of this
3253     class is to figure out these things, and to make sure that new
3254     commits are only detailed once even if they were added to multiple
3255     references.
3256
3257     The first step is to determine the "other" references--those
3258     unaffected by the current push.  They are computed by listing all
3259     references then removing any affected by this push.  The results
3260     are stored in Push._other_ref_sha1s.
3261
3262     The commits contained in the repository before this push were
3263
3264         git rev-list other1 other2 other3 ... change1.old change2.old ...
3265
3266     Where "changeN.old" is the old value of one of the references
3267     affected by this push.
3268
3269     The commits contained in the repository after this push are
3270
3271         git rev-list other1 other2 other3 ... change1.new change2.new ...
3272
3273     The commits added by this push are the difference between these
3274     two sets, which can be written
3275
3276         git rev-list \
3277             ^other1 ^other2 ... \
3278             ^change1.old ^change2.old ... \
3279             change1.new change2.new ...
3280
3281     The commits removed by this push can be computed by
3282
3283         git rev-list \
3284             ^other1 ^other2 ... \
3285             ^change1.new ^change2.new ... \
3286             change1.old change2.old ...
3287
3288     The last point is that it is possible that other pushes are
3289     occurring simultaneously to this one, so reference values can
3290     change at any time.  It is impossible to eliminate all race
3291     conditions, but we reduce the window of time during which problems
3292     can occur by translating reference names to SHA1s as soon as
3293     possible and working with SHA1s thereafter (because SHA1s are
3294     immutable)."""
3295
3296     # A map {(changeclass, changetype): integer} specifying the order
3297     # that reference changes will be processed if multiple reference
3298     # changes are included in a single push.  The order is significant
3299     # mostly because new commit notifications are threaded together
3300     # with the first reference change that includes the commit.  The
3301     # following order thus causes commits to be grouped with branch
3302     # changes (as opposed to tag changes) if possible.
3303     SORT_ORDER = dict(
3304         (value, i) for (i, value) in enumerate([
3305             (BranchChange, 'update'),
3306             (BranchChange, 'create'),
3307             (AnnotatedTagChange, 'update'),
3308             (AnnotatedTagChange, 'create'),
3309             (NonAnnotatedTagChange, 'update'),
3310             (NonAnnotatedTagChange, 'create'),
3311             (BranchChange, 'delete'),
3312             (AnnotatedTagChange, 'delete'),
3313             (NonAnnotatedTagChange, 'delete'),
3314             (OtherReferenceChange, 'update'),
3315             (OtherReferenceChange, 'create'),
3316             (OtherReferenceChange, 'delete'),
3317             ])
3318         )
3319
3320     def __init__(self, environment, changes, ignore_other_refs=False):
3321         self.changes = sorted(changes, key=self._sort_key)
3322         self.__other_ref_sha1s = None
3323         self.__cached_commits_spec = {}
3324         self.environment = environment
3325
3326         if ignore_other_refs:
3327             self.__other_ref_sha1s = set()
3328
3329     @classmethod
3330     def _sort_key(klass, change):
3331         return (klass.SORT_ORDER[change.__class__, change.change_type], change.refname,)
3332
3333     @property
3334     def _other_ref_sha1s(self):
3335         """The GitObjects referred to by references unaffected by this push.
3336         """
3337         if self.__other_ref_sha1s is None:
3338             # The refnames being changed by this push:
3339             updated_refs = set(
3340                 change.refname
3341                 for change in self.changes
3342                 )
3343
3344             # The SHA-1s of commits referred to by all references in this
3345             # repository *except* updated_refs:
3346             sha1s = set()
3347             fmt = (
3348                 '%(objectname) %(objecttype) %(refname)\n'
3349                 '%(*objectname) %(*objecttype) %(refname)'
3350                 )
3351             ref_filter_regex, is_inclusion_filter = \
3352                 self.environment.get_ref_filter_regex()
3353             for line in read_git_lines(
3354                     ['for-each-ref', '--format=%s' % (fmt,)]):
3355                 (sha1, type, name) = line.split(' ', 2)
3356                 if (sha1 and type == 'commit' and
3357                         name not in updated_refs and
3358                         include_ref(name, ref_filter_regex, is_inclusion_filter)):
3359                     sha1s.add(sha1)
3360
3361             self.__other_ref_sha1s = sha1s
3362
3363         return self.__other_ref_sha1s
3364
3365     def _get_commits_spec_incl(self, new_or_old, reference_change=None):
3366         """Get new or old SHA-1 from one or each of the changed refs.
3367
3368         Return a list of SHA-1 commit identifier strings suitable as
3369         arguments to 'git rev-list' (or 'git log' or ...).  The
3370         returned identifiers are either the old or new values from one
3371         or all of the changed references, depending on the values of
3372         new_or_old and reference_change.
3373
3374         new_or_old is either the string 'new' or the string 'old'.  If
3375         'new', the returned SHA-1 identifiers are the new values from
3376         each changed reference.  If 'old', the SHA-1 identifiers are
3377         the old values from each changed reference.
3378
3379         If reference_change is specified and not None, only the new or
3380         old reference from the specified reference is included in the
3381         return value.
3382
3383         This function returns None if there are no matching revisions
3384         (e.g., because a branch was deleted and new_or_old is 'new').
3385         """
3386
3387         if not reference_change:
3388             incl_spec = sorted(
3389                 getattr(change, new_or_old).sha1
3390                 for change in self.changes
3391                 if getattr(change, new_or_old)
3392                 )
3393             if not incl_spec:
3394                 incl_spec = None
3395         elif not getattr(reference_change, new_or_old).commit_sha1:
3396             incl_spec = None
3397         else:
3398             incl_spec = [getattr(reference_change, new_or_old).commit_sha1]
3399         return incl_spec
3400
3401     def _get_commits_spec_excl(self, new_or_old):
3402         """Get exclusion revisions for determining new or discarded commits.
3403
3404         Return a list of strings suitable as arguments to 'git
3405         rev-list' (or 'git log' or ...) that will exclude all
3406         commits that, depending on the value of new_or_old, were
3407         either previously in the repository (useful for determining
3408         which commits are new to the repository) or currently in the
3409         repository (useful for determining which commits were
3410         discarded from the repository).
3411
3412         new_or_old is either the string 'new' or the string 'old'.  If
3413         'new', the commits to be excluded are those that were in the
3414         repository before the push.  If 'old', the commits to be
3415         excluded are those that are currently in the repository.  """
3416
3417         old_or_new = {'old': 'new', 'new': 'old'}[new_or_old]
3418         excl_revs = self._other_ref_sha1s.union(
3419             getattr(change, old_or_new).sha1
3420             for change in self.changes
3421             if getattr(change, old_or_new).type in ['commit', 'tag']
3422             )
3423         return ['^' + sha1 for sha1 in sorted(excl_revs)]
3424
3425     def get_commits_spec(self, new_or_old, reference_change=None):
3426         """Get rev-list arguments for added or discarded commits.
3427
3428         Return a list of strings suitable as arguments to 'git
3429         rev-list' (or 'git log' or ...) that select those commits
3430         that, depending on the value of new_or_old, are either new to
3431         the repository or were discarded from the repository.
3432
3433         new_or_old is either the string 'new' or the string 'old'.  If
3434         'new', the returned list is used to select commits that are
3435         new to the repository.  If 'old', the returned value is used
3436         to select the commits that have been discarded from the
3437         repository.
3438
3439         If reference_change is specified and not None, the new or
3440         discarded commits are limited to those that are reachable from
3441         the new or old value of the specified reference.
3442
3443         This function returns None if there are no added (or discarded)
3444         revisions.
3445         """
3446         key = (new_or_old, reference_change)
3447         if key not in self.__cached_commits_spec:
3448             ret = self._get_commits_spec_incl(new_or_old, reference_change)
3449             if ret is not None:
3450                 ret.extend(self._get_commits_spec_excl(new_or_old))
3451             self.__cached_commits_spec[key] = ret
3452         return self.__cached_commits_spec[key]
3453
3454     def get_new_commits(self, reference_change=None):
3455         """Return a list of commits added by this push.
3456
3457         Return a list of the object names of commits that were added
3458         by the part of this push represented by reference_change.  If
3459         reference_change is None, then return a list of *all* commits
3460         added by this push."""
3461
3462         spec = self.get_commits_spec('new', reference_change)
3463         return git_rev_list(spec)
3464
3465     def get_discarded_commits(self, reference_change):
3466         """Return a list of commits discarded by this push.
3467
3468         Return a list of the object names of commits that were
3469         entirely discarded from the repository by the part of this
3470         push represented by reference_change."""
3471
3472         spec = self.get_commits_spec('old', reference_change)
3473         return git_rev_list(spec)
3474
3475     def send_emails(self, mailer, body_filter=None):
3476         """Use send all of the notification emails needed for this push.
3477
3478         Use send all of the notification emails (including reference
3479         change emails and commit emails) needed for this push.  Send
3480         the emails using mailer.  If body_filter is not None, then use
3481         it to filter the lines that are intended for the email
3482         body."""
3483
3484         # The sha1s of commits that were introduced by this push.
3485         # They will be removed from this set as they are processed, to
3486         # guarantee that one (and only one) email is generated for
3487         # each new commit.
3488         unhandled_sha1s = set(self.get_new_commits())
3489         send_date = IncrementalDateTime()
3490         for change in self.changes:
3491             sha1s = []
3492             for sha1 in reversed(list(self.get_new_commits(change))):
3493                 if sha1 in unhandled_sha1s:
3494                     sha1s.append(sha1)
3495                     unhandled_sha1s.remove(sha1)
3496
3497             # Check if we've got anyone to send to
3498             if not change.recipients:
3499                 change.environment.log_warning(
3500                     '*** no recipients configured so no email will be sent\n'
3501                     '*** for %r update %s->%s\n'
3502                     % (change.refname, change.old.sha1, change.new.sha1,)
3503                     )
3504             else:
3505                 if not change.environment.quiet:
3506                     change.environment.log_msg(
3507                         'Sending notification emails to: %s\n' % (change.recipients,))
3508                 extra_values = {'send_date': next(send_date)}
3509
3510                 rev = change.send_single_combined_email(sha1s)
3511                 if rev:
3512                     mailer.send(
3513                         change.generate_combined_email(self, rev, body_filter, extra_values),
3514                         rev.recipients,
3515                         )
3516                     # This change is now fully handled; no need to handle
3517                     # individual revisions any further.
3518                     continue
3519                 else:
3520                     mailer.send(
3521                         change.generate_email(self, body_filter, extra_values),
3522                         change.recipients,
3523                         )
3524
3525             max_emails = change.environment.maxcommitemails
3526             if max_emails and len(sha1s) > max_emails:
3527                 change.environment.log_warning(
3528                     '*** Too many new commits (%d), not sending commit emails.\n' % len(sha1s) +
3529                     '*** Try setting multimailhook.maxCommitEmails to a greater value\n' +
3530                     '*** Currently, multimailhook.maxCommitEmails=%d\n' % max_emails
3531                     )
3532                 return
3533
3534             for (num, sha1) in enumerate(sha1s):
3535                 rev = Revision(change, GitObject(sha1), num=num + 1, tot=len(sha1s))
3536                 if not rev.recipients and rev.cc_recipients:
3537                     change.environment.log_msg('*** Replacing Cc: with To:\n')
3538                     rev.recipients = rev.cc_recipients
3539                     rev.cc_recipients = None
3540                 if rev.recipients:
3541                     extra_values = {'send_date': next(send_date)}
3542                     mailer.send(
3543                         rev.generate_email(self, body_filter, extra_values),
3544                         rev.recipients,
3545                         )
3546
3547         # Consistency check:
3548         if unhandled_sha1s:
3549             change.environment.log_error(
3550                 'ERROR: No emails were sent for the following new commits:\n'
3551                 '    %s\n'
3552                 % ('\n    '.join(sorted(unhandled_sha1s)),)
3553                 )
3554
3555
3556 def include_ref(refname, ref_filter_regex, is_inclusion_filter):
3557     does_match = bool(ref_filter_regex.search(refname))
3558     if is_inclusion_filter:
3559         return does_match
3560     else:  # exclusion filter -- we include the ref if the regex doesn't match
3561         return not does_match
3562
3563
3564 def run_as_post_receive_hook(environment, mailer):
3565     ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(True)
3566     changes = []
3567     for line in sys.stdin:
3568         (oldrev, newrev, refname) = line.strip().split(' ', 2)
3569         if not include_ref(refname, ref_filter_regex, is_inclusion_filter):
3570             continue
3571         changes.append(
3572             ReferenceChange.create(environment, oldrev, newrev, refname)
3573             )
3574     if changes:
3575         push = Push(environment, changes)
3576         push.send_emails(mailer, body_filter=environment.filter_body)
3577     if hasattr(mailer, '__del__'):
3578         mailer.__del__()
3579
3580
3581 def run_as_update_hook(environment, mailer, refname, oldrev, newrev, force_send=False):
3582     ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(True)
3583     if not include_ref(refname, ref_filter_regex, is_inclusion_filter):
3584         return
3585     changes = [
3586         ReferenceChange.create(
3587             environment,
3588             read_git_output(['rev-parse', '--verify', oldrev]),
3589             read_git_output(['rev-parse', '--verify', newrev]),
3590             refname,
3591             ),
3592         ]
3593     push = Push(environment, changes, force_send)
3594     push.send_emails(mailer, body_filter=environment.filter_body)
3595     if hasattr(mailer, '__del__'):
3596         mailer.__del__()
3597
3598
3599 def choose_mailer(config, environment):
3600     mailer = config.get('mailer', default='sendmail')
3601
3602     if mailer == 'smtp':
3603         smtpserver = config.get('smtpserver', default='localhost')
3604         smtpservertimeout = float(config.get('smtpservertimeout', default=10.0))
3605         smtpserverdebuglevel = int(config.get('smtpserverdebuglevel', default=0))
3606         smtpencryption = config.get('smtpencryption', default='none')
3607         smtpuser = config.get('smtpuser', default='')
3608         smtppass = config.get('smtppass', default='')
3609         smtpcacerts = config.get('smtpcacerts', default='')
3610         mailer = SMTPMailer(
3611             envelopesender=(environment.get_sender() or environment.get_fromaddr()),
3612             smtpserver=smtpserver, smtpservertimeout=smtpservertimeout,
3613             smtpserverdebuglevel=smtpserverdebuglevel,
3614             smtpencryption=smtpencryption,
3615             smtpuser=smtpuser,
3616             smtppass=smtppass,
3617             smtpcacerts=smtpcacerts
3618             )
3619     elif mailer == 'sendmail':
3620         command = config.get('sendmailcommand')
3621         if command:
3622             command = shlex.split(command)
3623         mailer = SendMailer(command=command, envelopesender=environment.get_sender())
3624     else:
3625         environment.log_error(
3626             'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n' % mailer +
3627             'please use one of "smtp" or "sendmail".\n'
3628             )
3629         sys.exit(1)
3630     return mailer
3631
3632
3633 KNOWN_ENVIRONMENTS = {
3634     'generic': GenericEnvironmentMixin,
3635     'gitolite': GitoliteEnvironmentMixin,
3636     'stash': StashEnvironmentMixin,
3637     'gerrit': GerritEnvironmentMixin,
3638     }
3639
3640
3641 def choose_environment(config, osenv=None, env=None, recipients=None,
3642                        hook_info=None):
3643     if not osenv:
3644         osenv = os.environ
3645
3646     environment_mixins = [
3647         ConfigRefFilterEnvironmentMixin,
3648         ProjectdescEnvironmentMixin,
3649         ConfigMaxlinesEnvironmentMixin,
3650         ComputeFQDNEnvironmentMixin,
3651         ConfigFilterLinesEnvironmentMixin,
3652         PusherDomainEnvironmentMixin,
3653         ConfigOptionsEnvironmentMixin,
3654         ]
3655     environment_kw = {
3656         'osenv': osenv,
3657         'config': config,
3658         }
3659
3660     if not env:
3661         env = config.get('environment')
3662
3663     if not env:
3664         if 'GL_USER' in osenv and 'GL_REPO' in osenv:
3665             env = 'gitolite'
3666         else:
3667             env = 'generic'
3668
3669     environment_mixins.insert(0, KNOWN_ENVIRONMENTS[env])
3670
3671     if env == 'stash':
3672         environment_kw['user'] = hook_info['stash_user']
3673         environment_kw['repo'] = hook_info['stash_repo']
3674     elif env == 'gerrit':
3675         environment_kw['project'] = hook_info['project']
3676         environment_kw['submitter'] = hook_info['submitter']
3677         environment_kw['update_method'] = hook_info['update_method']
3678
3679     if recipients:
3680         environment_mixins.insert(0, StaticRecipientsEnvironmentMixin)
3681         environment_kw['refchange_recipients'] = recipients
3682         environment_kw['announce_recipients'] = recipients
3683         environment_kw['revision_recipients'] = recipients
3684         environment_kw['scancommitforcc'] = config.get('scancommitforcc')
3685     else:
3686         environment_mixins.insert(0, ConfigRecipientsEnvironmentMixin)
3687
3688     environment_klass = type(
3689         'EffectiveEnvironment',
3690         tuple(environment_mixins) + (Environment,),
3691         {},
3692         )
3693     return environment_klass(**environment_kw)
3694
3695
3696 def get_version():
3697     oldcwd = os.getcwd()
3698     try:
3699         try:
3700             os.chdir(os.path.dirname(os.path.realpath(__file__)))
3701             git_version = read_git_output(['describe', '--tags', 'HEAD'])
3702             if git_version == __version__:
3703                 return git_version
3704             else:
3705                 return '%s (%s)' % (__version__, git_version)
3706         except:
3707             pass
3708     finally:
3709         os.chdir(oldcwd)
3710     return __version__
3711
3712
3713 def compute_gerrit_options(options, args, required_gerrit_options):
3714     if None in required_gerrit_options:
3715         raise SystemExit("Error: Specify all of --oldrev, --newrev, --refname, "
3716                          "and --project; or none of them.")
3717
3718     if options.environment not in (None, 'gerrit'):
3719         raise SystemExit("Non-gerrit environments incompatible with --oldrev, "
3720                          "--newrev, --refname, and --project")
3721     options.environment = 'gerrit'
3722
3723     if args:
3724         raise SystemExit("Error: Positional parameters not allowed with "
3725                          "--oldrev, --newrev, and --refname.")
3726
3727     # Gerrit oddly omits 'refs/heads/' in the refname when calling
3728     # ref-updated hook; put it back.
3729     git_dir = get_git_dir()
3730     if (not os.path.exists(os.path.join(git_dir, options.refname)) and
3731         os.path.exists(os.path.join(git_dir, 'refs', 'heads',
3732                                     options.refname))):
3733         options.refname = 'refs/heads/' + options.refname
3734
3735     # Convert each string option unicode for Python3.
3736     if PYTHON3:
3737         opts = ['environment', 'recipients', 'oldrev', 'newrev', 'refname',
3738                 'project', 'submitter', 'stash-user', 'stash-repo']
3739         for opt in opts:
3740             if not hasattr(options, opt):
3741                 continue
3742             obj = getattr(options, opt)
3743             if obj:
3744                 enc = obj.encode('utf-8', 'surrogateescape')
3745                 dec = enc.decode('utf-8', 'replace')
3746                 setattr(options, opt, dec)
3747
3748     # New revisions can appear in a gerrit repository either due to someone
3749     # pushing directly (in which case options.submitter will be set), or they
3750     # can press "Submit this patchset" in the web UI for some CR (in which
3751     # case options.submitter will not be set and gerrit will not have provided
3752     # us the information about who pressed the button).
3753     #
3754     # Note for the nit-picky: I'm lumping in REST API calls and the ssh
3755     # gerrit review command in with "Submit this patchset" button, since they
3756     # have the same effect.
3757     if options.submitter:
3758         update_method = 'pushed'
3759         # The submitter argument is almost an RFC 2822 email address; change it
3760         # from 'User Name (email@domain)' to 'User Name <email@domain>' so it is
3761         options.submitter = options.submitter.replace('(', '<').replace(')', '>')
3762     else:
3763         update_method = 'submitted'
3764         # Gerrit knew who submitted this patchset, but threw that information
3765         # away when it invoked this hook.  However, *IF* Gerrit created a
3766         # merge to bring the patchset in (project 'Submit Type' is either
3767         # "Always Merge", or is "Merge if Necessary" and happens to be
3768         # necessary for this particular CR), then it will have the committer
3769         # of that merge be 'Gerrit Code Review' and the author will be the
3770         # person who requested the submission of the CR.  Since this is fairly
3771         # likely for most gerrit installations (of a reasonable size), it's
3772         # worth the extra effort to try to determine the actual submitter.
3773         rev_info = read_git_lines(['log', '--no-walk', '--merges',
3774                                    '--format=%cN%n%aN <%aE>', options.newrev])
3775         if rev_info and rev_info[0] == 'Gerrit Code Review':
3776             options.submitter = rev_info[1]
3777
3778     # We pass back refname, oldrev, newrev as args because then the
3779     # gerrit ref-updated hook is much like the git update hook
3780     return (options,
3781             [options.refname, options.oldrev, options.newrev],
3782             {'project': options.project, 'submitter': options.submitter,
3783              'update_method': update_method})
3784
3785
3786 def check_hook_specific_args(options, args):
3787     # First check for stash arguments
3788     if (options.stash_user is None) != (options.stash_repo is None):
3789         raise SystemExit("Error: Specify both of --stash-user and "
3790                          "--stash-repo or neither.")
3791     if options.stash_user:
3792         options.environment = 'stash'
3793         return options, args, {'stash_user': options.stash_user,
3794                                'stash_repo': options.stash_repo}
3795
3796     # Finally, check for gerrit specific arguments
3797     required_gerrit_options = (options.oldrev, options.newrev, options.refname,
3798                                options.project)
3799     if required_gerrit_options != (None,) * 4:
3800         return compute_gerrit_options(options, args, required_gerrit_options)
3801
3802     # No special options in use, just return what we started with
3803     return options, args, {}
3804
3805
3806 def main(args):
3807     parser = optparse.OptionParser(
3808         description=__doc__,
3809         usage='%prog [OPTIONS]\n   or: %prog [OPTIONS] REFNAME OLDREV NEWREV',
3810         )
3811
3812     parser.add_option(
3813         '--environment', '--env', action='store', type='choice',
3814         choices=list(KNOWN_ENVIRONMENTS.keys()), default=None,
3815         help=(
3816             'Choose type of environment is in use.  Default is taken from '
3817             'multimailhook.environment if set; otherwise "generic".'
3818             ),
3819         )
3820     parser.add_option(
3821         '--stdout', action='store_true', default=False,
3822         help='Output emails to stdout rather than sending them.',
3823         )
3824     parser.add_option(
3825         '--recipients', action='store', default=None,
3826         help='Set list of email recipients for all types of emails.',
3827         )
3828     parser.add_option(
3829         '--show-env', action='store_true', default=False,
3830         help=(
3831             'Write to stderr the values determined for the environment '
3832             '(intended for debugging purposes).'
3833             ),
3834         )
3835     parser.add_option(
3836         '--force-send', action='store_true', default=False,
3837         help=(
3838             'Force sending refchange email when using as an update hook. '
3839             'This is useful to work around the unreliable new commits '
3840             'detection in this mode.'
3841             ),
3842         )
3843     parser.add_option(
3844         '-c', metavar="<name>=<value>", action='append',
3845         help=(
3846             'Pass a configuration parameter through to git.  The value given '
3847             'will override values from configuration files.  See the -c option '
3848             'of git(1) for more details.  (Only works with git >= 1.7.3)'
3849             ),
3850         )
3851     parser.add_option(
3852         '--version', '-v', action='store_true', default=False,
3853         help=(
3854             "Display git-multimail's version"
3855             ),
3856         )
3857     # The following options permit this script to be run as a gerrit
3858     # ref-updated hook.  See e.g.
3859     # code.google.com/p/gerrit/source/browse/Documentation/config-hooks.txt
3860     # We suppress help for these items, since these are specific to gerrit,
3861     # and we don't want users directly using them any way other than how the
3862     # gerrit ref-updated hook is called.
3863     parser.add_option('--oldrev', action='store', help=optparse.SUPPRESS_HELP)
3864     parser.add_option('--newrev', action='store', help=optparse.SUPPRESS_HELP)
3865     parser.add_option('--refname', action='store', help=optparse.SUPPRESS_HELP)
3866     parser.add_option('--project', action='store', help=optparse.SUPPRESS_HELP)
3867     parser.add_option('--submitter', action='store', help=optparse.SUPPRESS_HELP)
3868
3869     # The following allow this to be run as a stash asynchronous post-receive
3870     # hook (almost identical to a git post-receive hook but triggered also for
3871     # merges of pull requests from the UI).  We suppress help for these items,
3872     # since these are specific to stash.
3873     parser.add_option('--stash-user', action='store', help=optparse.SUPPRESS_HELP)
3874     parser.add_option('--stash-repo', action='store', help=optparse.SUPPRESS_HELP)
3875
3876     (options, args) = parser.parse_args(args)
3877     (options, args, hook_info) = check_hook_specific_args(options, args)
3878
3879     if options.version:
3880         sys.stdout.write('git-multimail version ' + get_version() + '\n')
3881         return
3882
3883     if options.c:
3884         Config.add_config_parameters(options.c)
3885
3886     config = Config('multimailhook')
3887
3888     try:
3889         environment = choose_environment(
3890             config, osenv=os.environ,
3891             env=options.environment,
3892             recipients=options.recipients,
3893             hook_info=hook_info,
3894             )
3895
3896         if options.show_env:
3897             sys.stderr.write('Environment values:\n')
3898             for (k, v) in sorted(environment.get_values().items()):
3899                 sys.stderr.write('    %s : %r\n' % (k, v))
3900             sys.stderr.write('\n')
3901
3902         if options.stdout or environment.stdout:
3903             mailer = OutputMailer(sys.stdout)
3904         else:
3905             mailer = choose_mailer(config, environment)
3906
3907         # Dual mode: if arguments were specified on the command line, run
3908         # like an update hook; otherwise, run as a post-receive hook.
3909         if args:
3910             if len(args) != 3:
3911                 parser.error('Need zero or three non-option arguments')
3912             (refname, oldrev, newrev) = args
3913             run_as_update_hook(environment, mailer, refname, oldrev, newrev, options.force_send)
3914         else:
3915             run_as_post_receive_hook(environment, mailer)
3916     except ConfigurationException:
3917         sys.exit(sys.exc_info()[1])
3918     except Exception:
3919         t, e, tb = sys.exc_info()
3920         import traceback
3921         sys.stdout.write('\n')
3922         sys.stdout.write('Exception \'' + t.__name__ +
3923                          '\' raised. Please report this as a bug to\n')
3924         sys.stdout.write('https://github.com/git-multimail/git-multimail/issues\n')
3925         sys.stdout.write('with the information below:\n\n')
3926         sys.stdout.write('git-multimail version ' + get_version() + '\n')
3927         sys.stdout.write('Python version ' + sys.version + '\n')
3928         traceback.print_exc(file=sys.stdout)
3929         sys.exit(1)
3930
3931 if __name__ == '__main__':
3932     main(sys.argv[1:])