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