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