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