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