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