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