contrib / hooks / multimail / git_multimail.pyon commit t7300: repair filesystem permissions with test_when_finished (45067fc)
   1#! /usr/bin/env python2
   2
   3# Copyright (c) 2012,2013 Michael Haggerty
   4# Derived from contrib/hooks/post-receive-email, which is
   5# Copyright (c) 2007 Andy Parkins
   6# and also includes contributions by other authors.
   7#
   8# This file is part of git-multimail.
   9#
  10# git-multimail is free software: you can redistribute it and/or
  11# modify it under the terms of the GNU General Public License version
  12# 2 as published by the Free Software Foundation.
  13#
  14# This program is distributed in the hope that it will be useful, but
  15# WITHOUT ANY WARRANTY; without even the implied warranty of
  16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
  17# General Public License for more details.
  18#
  19# You should have received a copy of the GNU General Public License
  20# along with this program.  If not, see
  21# <http://www.gnu.org/licenses/>.
  22
  23"""Generate notification emails for pushes to a git repository.
  24
  25This hook sends emails describing changes introduced by pushes to a
  26git repository.  For each reference that was changed, it emits one
  27ReferenceChange email summarizing how the reference was changed,
  28followed by one Revision email for each new commit that was introduced
  29by the reference change.
  30
  31Each commit is announced in exactly one Revision email.  If the same
  32commit is merged into another branch in the same or a later push, then
  33the ReferenceChange email will list the commit's SHA1 and its one-line
  34summary, but no new Revision email will be generated.
  35
  36This script is designed to be used as a "post-receive" hook in a git
  37repository (see githooks(5)).  It can also be used as an "update"
  38script, but this usage is not completely reliable and is deprecated.
  39
  40To help with debugging, this script accepts a --stdout option, which
  41causes the emails to be written to standard output rather than sent
  42using sendmail.
  43
  44See the accompanying README file for the complete documentation.
  45
  46"""
  47
  48import sys
  49import os
  50import re
  51import bisect
  52import subprocess
  53import shlex
  54import optparse
  55import smtplib
  56
  57try:
  58    from email.utils import make_msgid
  59    from email.utils import getaddresses
  60    from email.utils import formataddr
  61    from email.header import Header
  62except ImportError:
  63    # Prior to Python 2.5, the email module used different names:
  64    from email.Utils import make_msgid
  65    from email.Utils import getaddresses
  66    from email.Utils import formataddr
  67    from email.Header import Header
  68
  69
  70DEBUG = False
  71
  72ZEROS = '0' * 40
  73LOGBEGIN = '- Log -----------------------------------------------------------------\n'
  74LOGEND = '-----------------------------------------------------------------------\n'
  75
  76
  77# It is assumed in many places that the encoding is uniformly UTF-8,
  78# so changing these constants is unsupported.  But define them here
  79# anyway, to make it easier to find (at least most of) the places
  80# where the encoding is important.
  81(ENCODING, CHARSET) = ('UTF-8', 'utf-8')
  82
  83
  84REF_CREATED_SUBJECT_TEMPLATE = (
  85    '%(emailprefix)s%(refname_type)s %(short_refname)s created'
  86    ' (now %(newrev_short)s)'
  87    )
  88REF_UPDATED_SUBJECT_TEMPLATE = (
  89    '%(emailprefix)s%(refname_type)s %(short_refname)s updated'
  90    ' (%(oldrev_short)s -> %(newrev_short)s)'
  91    )
  92REF_DELETED_SUBJECT_TEMPLATE = (
  93    '%(emailprefix)s%(refname_type)s %(short_refname)s deleted'
  94    ' (was %(oldrev_short)s)'
  95    )
  96
  97REFCHANGE_HEADER_TEMPLATE = """\
  98To: %(recipients)s
  99Subject: %(subject)s
 100MIME-Version: 1.0
 101Content-Type: text/plain; charset=%(charset)s
 102Content-Transfer-Encoding: 8bit
 103Message-ID: %(msgid)s
 104From: %(fromaddr)s
 105Reply-To: %(reply_to)s
 106X-Git-Repo: %(repo_shortname)s
 107X-Git-Refname: %(refname)s
 108X-Git-Reftype: %(refname_type)s
 109X-Git-Oldrev: %(oldrev)s
 110X-Git-Newrev: %(newrev)s
 111Auto-Submitted: auto-generated
 112"""
 113
 114REFCHANGE_INTRO_TEMPLATE = """\
 115This is an automated email from the git hooks/post-receive script.
 116
 117%(pusher)s pushed a change to %(refname_type)s %(short_refname)s
 118in repository %(repo_shortname)s.
 119
 120"""
 121
 122
 123FOOTER_TEMPLATE = """\
 124
 125-- \n\
 126To stop receiving notification emails like this one, please contact
 127%(administrator)s.
 128"""
 129
 130
 131REWIND_ONLY_TEMPLATE = """\
 132This update removed existing revisions from the reference, leaving the
 133reference pointing at a previous point in the repository history.
 134
 135 * -- * -- N   %(refname)s (%(newrev_short)s)
 136            \\
 137             O -- O -- O   (%(oldrev_short)s)
 138
 139Any revisions marked "omits" are not gone; other references still
 140refer to them.  Any revisions marked "discards" are gone forever.
 141"""
 142
 143
 144NON_FF_TEMPLATE = """\
 145This update added new revisions after undoing existing revisions.
 146That is to say, some revisions that were in the old version of the
 147%(refname_type)s are not in the new version.  This situation occurs
 148when a user --force pushes a change and generates a repository
 149containing something like this:
 150
 151 * -- * -- B -- O -- O -- O   (%(oldrev_short)s)
 152            \\
 153             N -- N -- N   %(refname)s (%(newrev_short)s)
 154
 155You should already have received notification emails for all of the O
 156revisions, and so the following emails describe only the N revisions
 157from the common base, B.
 158
 159Any revisions marked "omits" are not gone; other references still
 160refer to them.  Any revisions marked "discards" are gone forever.
 161"""
 162
 163
 164NO_NEW_REVISIONS_TEMPLATE = """\
 165No new revisions were added by this update.
 166"""
 167
 168
 169DISCARDED_REVISIONS_TEMPLATE = """\
 170This change permanently discards the following revisions:
 171"""
 172
 173
 174NO_DISCARDED_REVISIONS_TEMPLATE = """\
 175The revisions that were on this %(refname_type)s are still contained in
 176other references; therefore, this change does not discard any commits
 177from the repository.
 178"""
 179
 180
 181NEW_REVISIONS_TEMPLATE = """\
 182The %(tot)s revisions listed above as "new" are entirely new to this
 183repository and will be described in separate emails.  The revisions
 184listed as "adds" were already present in the repository and have only
 185been added to this reference.
 186
 187"""
 188
 189
 190TAG_CREATED_TEMPLATE = """\
 191        at  %(newrev_short)-9s (%(newrev_type)s)
 192"""
 193
 194
 195TAG_UPDATED_TEMPLATE = """\
 196*** WARNING: tag %(short_refname)s was modified! ***
 197
 198      from  %(oldrev_short)-9s (%(oldrev_type)s)
 199        to  %(newrev_short)-9s (%(newrev_type)s)
 200"""
 201
 202
 203TAG_DELETED_TEMPLATE = """\
 204*** WARNING: tag %(short_refname)s was deleted! ***
 205
 206"""
 207
 208
 209# The template used in summary tables.  It looks best if this uses the
 210# same alignment as TAG_CREATED_TEMPLATE and TAG_UPDATED_TEMPLATE.
 211BRIEF_SUMMARY_TEMPLATE = """\
 212%(action)10s  %(rev_short)-9s %(text)s
 213"""
 214
 215
 216NON_COMMIT_UPDATE_TEMPLATE = """\
 217This is an unusual reference change because the reference did not
 218refer to a commit either before or after the change.  We do not know
 219how to provide full information about this reference change.
 220"""
 221
 222
 223REVISION_HEADER_TEMPLATE = """\
 224To: %(recipients)s
 225Subject: %(emailprefix)s%(num)02d/%(tot)02d: %(oneline)s
 226MIME-Version: 1.0
 227Content-Type: text/plain; charset=%(charset)s
 228Content-Transfer-Encoding: 8bit
 229From: %(fromaddr)s
 230Reply-To: %(reply_to)s
 231In-Reply-To: %(reply_to_msgid)s
 232References: %(reply_to_msgid)s
 233X-Git-Repo: %(repo_shortname)s
 234X-Git-Refname: %(refname)s
 235X-Git-Reftype: %(refname_type)s
 236X-Git-Rev: %(rev)s
 237Auto-Submitted: auto-generated
 238"""
 239
 240REVISION_INTRO_TEMPLATE = """\
 241This is an automated email from the git hooks/post-receive script.
 242
 243%(pusher)s pushed a commit to %(refname_type)s %(short_refname)s
 244in repository %(repo_shortname)s.
 245
 246"""
 247
 248
 249REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE
 250
 251
 252class CommandError(Exception):
 253    def __init__(self, cmd, retcode):
 254        self.cmd = cmd
 255        self.retcode = retcode
 256        Exception.__init__(
 257            self,
 258            'Command "%s" failed with retcode %s' % (' '.join(cmd), retcode,)
 259            )
 260
 261
 262class ConfigurationException(Exception):
 263    pass
 264
 265
 266def read_git_output(args, input=None, keepends=False, **kw):
 267    """Read the output of a Git command."""
 268
 269    return read_output(
 270        ['git', '-c', 'i18n.logoutputencoding=%s' % (ENCODING,)] + args,
 271        input=input, keepends=keepends, **kw
 272        )
 273
 274
 275def read_output(cmd, input=None, keepends=False, **kw):
 276    if input:
 277        stdin = subprocess.PIPE
 278    else:
 279        stdin = None
 280    p = subprocess.Popen(
 281        cmd, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw
 282        )
 283    (out, err) = p.communicate(input)
 284    retcode = p.wait()
 285    if retcode:
 286        raise CommandError(cmd, retcode)
 287    if not keepends:
 288        out = out.rstrip('\n\r')
 289    return out
 290
 291
 292def read_git_lines(args, keepends=False, **kw):
 293    """Return the lines output by Git command.
 294
 295    Return as single lines, with newlines stripped off."""
 296
 297    return read_git_output(args, keepends=True, **kw).splitlines(keepends)
 298
 299
 300class Config(object):
 301    def __init__(self, section, git_config=None):
 302        """Represent a section of the git configuration.
 303
 304        If git_config is specified, it is passed to "git config" in
 305        the GIT_CONFIG environment variable, meaning that "git config"
 306        will read the specified path rather than the Git default
 307        config paths."""
 308
 309        self.section = section
 310        if git_config:
 311            self.env = os.environ.copy()
 312            self.env['GIT_CONFIG'] = git_config
 313        else:
 314            self.env = None
 315
 316    @staticmethod
 317    def _split(s):
 318        """Split NUL-terminated values."""
 319
 320        words = s.split('\0')
 321        assert words[-1] == ''
 322        return words[:-1]
 323
 324    def get(self, name, default=None):
 325        try:
 326            values = self._split(read_git_output(
 327                    ['config', '--get', '--null', '%s.%s' % (self.section, name)],
 328                    env=self.env, keepends=True,
 329                    ))
 330            assert len(values) == 1
 331            return values[0]
 332        except CommandError:
 333            return default
 334
 335    def get_bool(self, name, default=None):
 336        try:
 337            value = read_git_output(
 338                ['config', '--get', '--bool', '%s.%s' % (self.section, name)],
 339                env=self.env,
 340                )
 341        except CommandError:
 342            return default
 343        return value == 'true'
 344
 345    def get_all(self, name, default=None):
 346        """Read a (possibly multivalued) setting from the configuration.
 347
 348        Return the result as a list of values, or default if the name
 349        is unset."""
 350
 351        try:
 352            return self._split(read_git_output(
 353                ['config', '--get-all', '--null', '%s.%s' % (self.section, name)],
 354                env=self.env, keepends=True,
 355                ))
 356        except CommandError, e:
 357            if e.retcode == 1:
 358                # "the section or key is invalid"; i.e., there is no
 359                # value for the specified key.
 360                return default
 361            else:
 362                raise
 363
 364    def get_recipients(self, name, default=None):
 365        """Read a recipients list from the configuration.
 366
 367        Return the result as a comma-separated list of email
 368        addresses, or default if the option is unset.  If the setting
 369        has multiple values, concatenate them with comma separators."""
 370
 371        lines = self.get_all(name, default=None)
 372        if lines is None:
 373            return default
 374        return ', '.join(line.strip() for line in lines)
 375
 376    def set(self, name, value):
 377        read_git_output(
 378            ['config', '%s.%s' % (self.section, name), value],
 379            env=self.env,
 380            )
 381
 382    def add(self, name, value):
 383        read_git_output(
 384            ['config', '--add', '%s.%s' % (self.section, name), value],
 385            env=self.env,
 386            )
 387
 388    def has_key(self, name):
 389        return self.get_all(name, default=None) is not None
 390
 391    def unset_all(self, name):
 392        try:
 393            read_git_output(
 394                ['config', '--unset-all', '%s.%s' % (self.section, name)],
 395                env=self.env,
 396                )
 397        except CommandError, e:
 398            if e.retcode == 5:
 399                # The name doesn't exist, which is what we wanted anyway...
 400                pass
 401            else:
 402                raise
 403
 404    def set_recipients(self, name, value):
 405        self.unset_all(name)
 406        for pair in getaddresses([value]):
 407            self.add(name, formataddr(pair))
 408
 409
 410def generate_summaries(*log_args):
 411    """Generate a brief summary for each revision requested.
 412
 413    log_args are strings that will be passed directly to "git log" as
 414    revision selectors.  Iterate over (sha1_short, subject) for each
 415    commit specified by log_args (subject is the first line of the
 416    commit message as a string without EOLs)."""
 417
 418    cmd = [
 419        'log', '--abbrev', '--format=%h %s',
 420        ] + list(log_args) + ['--']
 421    for line in read_git_lines(cmd):
 422        yield tuple(line.split(' ', 1))
 423
 424
 425def limit_lines(lines, max_lines):
 426    for (index, line) in enumerate(lines):
 427        if index < max_lines:
 428            yield line
 429
 430    if index >= max_lines:
 431        yield '... %d lines suppressed ...\n' % (index + 1 - max_lines,)
 432
 433
 434def limit_linelength(lines, max_linelength):
 435    for line in lines:
 436        # Don't forget that lines always include a trailing newline.
 437        if len(line) > max_linelength + 1:
 438            line = line[:max_linelength - 7] + ' [...]\n'
 439        yield line
 440
 441
 442class CommitSet(object):
 443    """A (constant) set of object names.
 444
 445    The set should be initialized with full SHA1 object names.  The
 446    __contains__() method returns True iff its argument is an
 447    abbreviation of any the names in the set."""
 448
 449    def __init__(self, names):
 450        self._names = sorted(names)
 451
 452    def __len__(self):
 453        return len(self._names)
 454
 455    def __contains__(self, sha1_abbrev):
 456        """Return True iff this set contains sha1_abbrev (which might be abbreviated)."""
 457
 458        i = bisect.bisect_left(self._names, sha1_abbrev)
 459        return i < len(self) and self._names[i].startswith(sha1_abbrev)
 460
 461
 462class GitObject(object):
 463    def __init__(self, sha1, type=None):
 464        if sha1 == ZEROS:
 465            self.sha1 = self.type = self.commit_sha1 = None
 466        else:
 467            self.sha1 = sha1
 468            self.type = type or read_git_output(['cat-file', '-t', self.sha1])
 469
 470            if self.type == 'commit':
 471                self.commit_sha1 = self.sha1
 472            elif self.type == 'tag':
 473                try:
 474                    self.commit_sha1 = read_git_output(
 475                        ['rev-parse', '--verify', '%s^0' % (self.sha1,)]
 476                        )
 477                except CommandError:
 478                    # Cannot deref tag to determine commit_sha1
 479                    self.commit_sha1 = None
 480            else:
 481                self.commit_sha1 = None
 482
 483        self.short = read_git_output(['rev-parse', '--short', sha1])
 484
 485    def get_summary(self):
 486        """Return (sha1_short, subject) for this commit."""
 487
 488        if not self.sha1:
 489            raise ValueError('Empty commit has no summary')
 490
 491        return iter(generate_summaries('--no-walk', self.sha1)).next()
 492
 493    def __eq__(self, other):
 494        return isinstance(other, GitObject) and self.sha1 == other.sha1
 495
 496    def __hash__(self):
 497        return hash(self.sha1)
 498
 499    def __nonzero__(self):
 500        return bool(self.sha1)
 501
 502    def __str__(self):
 503        return self.sha1 or ZEROS
 504
 505
 506class Change(object):
 507    """A Change that has been made to the Git repository.
 508
 509    Abstract class from which both Revisions and ReferenceChanges are
 510    derived.  A Change knows how to generate a notification email
 511    describing itself."""
 512
 513    def __init__(self, environment):
 514        self.environment = environment
 515        self._values = None
 516
 517    def _compute_values(self):
 518        """Return a dictionary {keyword : expansion} for this Change.
 519
 520        Derived classes overload this method to add more entries to
 521        the return value.  This method is used internally by
 522        get_values().  The return value should always be a new
 523        dictionary."""
 524
 525        return self.environment.get_values()
 526
 527    def get_values(self, **extra_values):
 528        """Return a dictionary {keyword : expansion} for this Change.
 529
 530        Return a dictionary mapping keywords to the values that they
 531        should be expanded to for this Change (used when interpolating
 532        template strings).  If any keyword arguments are supplied, add
 533        those to the return value as well.  The return value is always
 534        a new dictionary."""
 535
 536        if self._values is None:
 537            self._values = self._compute_values()
 538
 539        values = self._values.copy()
 540        if extra_values:
 541            values.update(extra_values)
 542        return values
 543
 544    def expand(self, template, **extra_values):
 545        """Expand template.
 546
 547        Expand the template (which should be a string) using string
 548        interpolation of the values for this Change.  If any keyword
 549        arguments are provided, also include those in the keywords
 550        available for interpolation."""
 551
 552        return template % self.get_values(**extra_values)
 553
 554    def expand_lines(self, template, **extra_values):
 555        """Break template into lines and expand each line."""
 556
 557        values = self.get_values(**extra_values)
 558        for line in template.splitlines(True):
 559            yield line % values
 560
 561    def expand_header_lines(self, template, **extra_values):
 562        """Break template into lines and expand each line as an RFC 2822 header.
 563
 564        Encode values and split up lines that are too long.  Silently
 565        skip lines that contain references to unknown variables."""
 566
 567        values = self.get_values(**extra_values)
 568        for line in template.splitlines():
 569            (name, value) = line.split(':', 1)
 570
 571            try:
 572                value = value % values
 573            except KeyError, e:
 574                if DEBUG:
 575                    sys.stderr.write(
 576                        'Warning: unknown variable %r in the following line; line skipped:\n'
 577                        '    %s\n'
 578                        % (e.args[0], line,)
 579                        )
 580            else:
 581                try:
 582                    h = Header(value, header_name=name)
 583                except UnicodeDecodeError:
 584                    h = Header(value, header_name=name, charset=CHARSET, errors='replace')
 585                for splitline in ('%s: %s\n' % (name, h.encode(),)).splitlines(True):
 586                    yield splitline
 587
 588    def generate_email_header(self):
 589        """Generate the RFC 2822 email headers for this Change, a line at a time.
 590
 591        The output should not include the trailing blank line."""
 592
 593        raise NotImplementedError()
 594
 595    def generate_email_intro(self):
 596        """Generate the email intro for this Change, a line at a time.
 597
 598        The output will be used as the standard boilerplate at the top
 599        of the email body."""
 600
 601        raise NotImplementedError()
 602
 603    def generate_email_body(self):
 604        """Generate the main part of the email body, a line at a time.
 605
 606        The text in the body might be truncated after a specified
 607        number of lines (see multimailhook.emailmaxlines)."""
 608
 609        raise NotImplementedError()
 610
 611    def generate_email_footer(self):
 612        """Generate the footer of the email, a line at a time.
 613
 614        The footer is always included, irrespective of
 615        multimailhook.emailmaxlines."""
 616
 617        raise NotImplementedError()
 618
 619    def generate_email(self, push, body_filter=None):
 620        """Generate an email describing this change.
 621
 622        Iterate over the lines (including the header lines) of an
 623        email describing this change.  If body_filter is not None,
 624        then use it to filter the lines that are intended for the
 625        email body."""
 626
 627        for line in self.generate_email_header():
 628            yield line
 629        yield '\n'
 630        for line in self.generate_email_intro():
 631            yield line
 632
 633        body = self.generate_email_body(push)
 634        if body_filter is not None:
 635            body = body_filter(body)
 636        for line in body:
 637            yield line
 638
 639        for line in self.generate_email_footer():
 640            yield line
 641
 642
 643class Revision(Change):
 644    """A Change consisting of a single git commit."""
 645
 646    def __init__(self, reference_change, rev, num, tot):
 647        Change.__init__(self, reference_change.environment)
 648        self.reference_change = reference_change
 649        self.rev = rev
 650        self.change_type = self.reference_change.change_type
 651        self.refname = self.reference_change.refname
 652        self.num = num
 653        self.tot = tot
 654        self.author = read_git_output(['log', '--no-walk', '--format=%aN <%aE>', self.rev.sha1])
 655        self.recipients = self.environment.get_revision_recipients(self)
 656
 657    def _compute_values(self):
 658        values = Change._compute_values(self)
 659
 660        oneline = read_git_output(
 661            ['log', '--format=%s', '--no-walk', self.rev.sha1]
 662            )
 663
 664        values['rev'] = self.rev.sha1
 665        values['rev_short'] = self.rev.short
 666        values['change_type'] = self.change_type
 667        values['refname'] = self.refname
 668        values['short_refname'] = self.reference_change.short_refname
 669        values['refname_type'] = self.reference_change.refname_type
 670        values['reply_to_msgid'] = self.reference_change.msgid
 671        values['num'] = self.num
 672        values['tot'] = self.tot
 673        values['recipients'] = self.recipients
 674        values['oneline'] = oneline
 675        values['author'] = self.author
 676
 677        reply_to = self.environment.get_reply_to_commit(self)
 678        if reply_to:
 679            values['reply_to'] = reply_to
 680
 681        return values
 682
 683    def generate_email_header(self):
 684        for line in self.expand_header_lines(REVISION_HEADER_TEMPLATE):
 685            yield line
 686
 687    def generate_email_intro(self):
 688        for line in self.expand_lines(REVISION_INTRO_TEMPLATE):
 689            yield line
 690
 691    def generate_email_body(self, push):
 692        """Show this revision."""
 693
 694        return read_git_lines(
 695            [
 696                'log', '-C',
 697                 '--stat', '-p', '--cc',
 698                '-1', self.rev.sha1,
 699                ],
 700            keepends=True,
 701            )
 702
 703    def generate_email_footer(self):
 704        return self.expand_lines(REVISION_FOOTER_TEMPLATE)
 705
 706
 707class ReferenceChange(Change):
 708    """A Change to a Git reference.
 709
 710    An abstract class representing a create, update, or delete of a
 711    Git reference.  Derived classes handle specific types of reference
 712    (e.g., tags vs. branches).  These classes generate the main
 713    reference change email summarizing the reference change and
 714    whether it caused any any commits to be added or removed.
 715
 716    ReferenceChange objects are usually created using the static
 717    create() method, which has the logic to decide which derived class
 718    to instantiate."""
 719
 720    REF_RE = re.compile(r'^refs\/(?P<area>[^\/]+)\/(?P<shortname>.*)$')
 721
 722    @staticmethod
 723    def create(environment, oldrev, newrev, refname):
 724        """Return a ReferenceChange object representing the change.
 725
 726        Return an object that represents the type of change that is being
 727        made. oldrev and newrev should be SHA1s or ZEROS."""
 728
 729        old = GitObject(oldrev)
 730        new = GitObject(newrev)
 731        rev = new or old
 732
 733        # The revision type tells us what type the commit is, combined with
 734        # the location of the ref we can decide between
 735        #  - working branch
 736        #  - tracking branch
 737        #  - unannotated tag
 738        #  - annotated tag
 739        m = ReferenceChange.REF_RE.match(refname)
 740        if m:
 741            area = m.group('area')
 742            short_refname = m.group('shortname')
 743        else:
 744            area = ''
 745            short_refname = refname
 746
 747        if rev.type == 'tag':
 748            # Annotated tag:
 749            klass = AnnotatedTagChange
 750        elif rev.type == 'commit':
 751            if area == 'tags':
 752                # Non-annotated tag:
 753                klass = NonAnnotatedTagChange
 754            elif area == 'heads':
 755                # Branch:
 756                klass = BranchChange
 757            elif area == 'remotes':
 758                # Tracking branch:
 759                sys.stderr.write(
 760                    '*** Push-update of tracking branch %r\n'
 761                    '***  - incomplete email generated.\n'
 762                     % (refname,)
 763                    )
 764                klass = OtherReferenceChange
 765            else:
 766                # Some other reference namespace:
 767                sys.stderr.write(
 768                    '*** Push-update of strange reference %r\n'
 769                    '***  - incomplete email generated.\n'
 770                     % (refname,)
 771                    )
 772                klass = OtherReferenceChange
 773        else:
 774            # Anything else (is there anything else?)
 775            sys.stderr.write(
 776                '*** Unknown type of update to %r (%s)\n'
 777                '***  - incomplete email generated.\n'
 778                 % (refname, rev.type,)
 779                )
 780            klass = OtherReferenceChange
 781
 782        return klass(
 783            environment,
 784            refname=refname, short_refname=short_refname,
 785            old=old, new=new, rev=rev,
 786            )
 787
 788    def __init__(self, environment, refname, short_refname, old, new, rev):
 789        Change.__init__(self, environment)
 790        self.change_type = {
 791            (False, True) : 'create',
 792            (True, True) : 'update',
 793            (True, False) : 'delete',
 794            }[bool(old), bool(new)]
 795        self.refname = refname
 796        self.short_refname = short_refname
 797        self.old = old
 798        self.new = new
 799        self.rev = rev
 800        self.msgid = make_msgid()
 801        self.diffopts = environment.diffopts
 802        self.logopts = environment.logopts
 803        self.showlog = environment.refchange_showlog
 804
 805    def _compute_values(self):
 806        values = Change._compute_values(self)
 807
 808        values['change_type'] = self.change_type
 809        values['refname_type'] = self.refname_type
 810        values['refname'] = self.refname
 811        values['short_refname'] = self.short_refname
 812        values['msgid'] = self.msgid
 813        values['recipients'] = self.recipients
 814        values['oldrev'] = str(self.old)
 815        values['oldrev_short'] = self.old.short
 816        values['newrev'] = str(self.new)
 817        values['newrev_short'] = self.new.short
 818
 819        if self.old:
 820            values['oldrev_type'] = self.old.type
 821        if self.new:
 822            values['newrev_type'] = self.new.type
 823
 824        reply_to = self.environment.get_reply_to_refchange(self)
 825        if reply_to:
 826            values['reply_to'] = reply_to
 827
 828        return values
 829
 830    def get_subject(self):
 831        template = {
 832            'create' : REF_CREATED_SUBJECT_TEMPLATE,
 833            'update' : REF_UPDATED_SUBJECT_TEMPLATE,
 834            'delete' : REF_DELETED_SUBJECT_TEMPLATE,
 835            }[self.change_type]
 836        return self.expand(template)
 837
 838    def generate_email_header(self):
 839        for line in self.expand_header_lines(
 840            REFCHANGE_HEADER_TEMPLATE, subject=self.get_subject(),
 841            ):
 842            yield line
 843
 844    def generate_email_intro(self):
 845        for line in self.expand_lines(REFCHANGE_INTRO_TEMPLATE):
 846            yield line
 847
 848    def generate_email_body(self, push):
 849        """Call the appropriate body-generation routine.
 850
 851        Call one of generate_create_summary() /
 852        generate_update_summary() / generate_delete_summary()."""
 853
 854        change_summary = {
 855            'create' : self.generate_create_summary,
 856            'delete' : self.generate_delete_summary,
 857            'update' : self.generate_update_summary,
 858            }[self.change_type](push)
 859        for line in change_summary:
 860            yield line
 861
 862        for line in self.generate_revision_change_summary(push):
 863            yield line
 864
 865    def generate_email_footer(self):
 866        return self.expand_lines(FOOTER_TEMPLATE)
 867
 868    def generate_revision_change_log(self, new_commits_list):
 869        if self.showlog:
 870            yield '\n'
 871            yield 'Detailed log of new commits:\n\n'
 872            for line in read_git_lines(
 873                    ['log', '--no-walk']
 874                    + self.logopts
 875                    + new_commits_list
 876                    + ['--'],
 877                    keepends=True,
 878                ):
 879                yield line
 880
 881    def generate_revision_change_summary(self, push):
 882        """Generate a summary of the revisions added/removed by this change."""
 883
 884        if self.new.commit_sha1 and not self.old.commit_sha1:
 885            # A new reference was created.  List the new revisions
 886            # brought by the new reference (i.e., those revisions that
 887            # were not in the repository before this reference
 888            # change).
 889            sha1s = list(push.get_new_commits(self))
 890            sha1s.reverse()
 891            tot = len(sha1s)
 892            new_revisions = [
 893                Revision(self, GitObject(sha1), num=i+1, tot=tot)
 894                for (i, sha1) in enumerate(sha1s)
 895                ]
 896
 897            if new_revisions:
 898                yield self.expand('This %(refname_type)s includes the following new commits:\n')
 899                yield '\n'
 900                for r in new_revisions:
 901                    (sha1, subject) = r.rev.get_summary()
 902                    yield r.expand(
 903                        BRIEF_SUMMARY_TEMPLATE, action='new', text=subject,
 904                        )
 905                yield '\n'
 906                for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot):
 907                    yield line
 908                for line in self.generate_revision_change_log([r.rev.sha1 for r in new_revisions]):
 909                    yield line
 910            else:
 911                for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
 912                    yield line
 913
 914        elif self.new.commit_sha1 and self.old.commit_sha1:
 915            # A reference was changed to point at a different commit.
 916            # List the revisions that were removed and/or added *from
 917            # that reference* by this reference change, along with a
 918            # diff between the trees for its old and new values.
 919
 920            # List of the revisions that were added to the branch by
 921            # this update.  Note this list can include revisions that
 922            # have already had notification emails; we want such
 923            # revisions in the summary even though we will not send
 924            # new notification emails for them.
 925            adds = list(generate_summaries(
 926                    '--topo-order', '--reverse', '%s..%s'
 927                    % (self.old.commit_sha1, self.new.commit_sha1,)
 928                    ))
 929
 930            # List of the revisions that were removed from the branch
 931            # by this update.  This will be empty except for
 932            # non-fast-forward updates.
 933            discards = list(generate_summaries(
 934                    '%s..%s' % (self.new.commit_sha1, self.old.commit_sha1,)
 935                    ))
 936
 937            if adds:
 938                new_commits_list = push.get_new_commits(self)
 939            else:
 940                new_commits_list = []
 941            new_commits = CommitSet(new_commits_list)
 942
 943            if discards:
 944                discarded_commits = CommitSet(push.get_discarded_commits(self))
 945            else:
 946                discarded_commits = CommitSet([])
 947
 948            if discards and adds:
 949                for (sha1, subject) in discards:
 950                    if sha1 in discarded_commits:
 951                        action = 'discards'
 952                    else:
 953                        action = 'omits'
 954                    yield self.expand(
 955                        BRIEF_SUMMARY_TEMPLATE, action=action,
 956                        rev_short=sha1, text=subject,
 957                        )
 958                for (sha1, subject) in adds:
 959                    if sha1 in new_commits:
 960                        action = 'new'
 961                    else:
 962                        action = 'adds'
 963                    yield self.expand(
 964                        BRIEF_SUMMARY_TEMPLATE, action=action,
 965                        rev_short=sha1, text=subject,
 966                        )
 967                yield '\n'
 968                for line in self.expand_lines(NON_FF_TEMPLATE):
 969                    yield line
 970
 971            elif discards:
 972                for (sha1, subject) in discards:
 973                    if sha1 in discarded_commits:
 974                        action = 'discards'
 975                    else:
 976                        action = 'omits'
 977                    yield self.expand(
 978                        BRIEF_SUMMARY_TEMPLATE, action=action,
 979                        rev_short=sha1, text=subject,
 980                        )
 981                yield '\n'
 982                for line in self.expand_lines(REWIND_ONLY_TEMPLATE):
 983                    yield line
 984
 985            elif adds:
 986                (sha1, subject) = self.old.get_summary()
 987                yield self.expand(
 988                    BRIEF_SUMMARY_TEMPLATE, action='from',
 989                    rev_short=sha1, text=subject,
 990                    )
 991                for (sha1, subject) in adds:
 992                    if sha1 in new_commits:
 993                        action = 'new'
 994                    else:
 995                        action = 'adds'
 996                    yield self.expand(
 997                        BRIEF_SUMMARY_TEMPLATE, action=action,
 998                        rev_short=sha1, text=subject,
 999                        )
1000
1001            yield '\n'
1002
1003            if new_commits:
1004                for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=len(new_commits)):
1005                    yield line
1006                for line in self.generate_revision_change_log(new_commits_list):
1007                    yield line
1008            else:
1009                for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
1010                    yield line
1011
1012            # The diffstat is shown from the old revision to the new
1013            # revision.  This is to show the truth of what happened in
1014            # this change.  There's no point showing the stat from the
1015            # base to the new revision because the base is effectively a
1016            # random revision at this point - the user will be interested
1017            # in what this revision changed - including the undoing of
1018            # previous revisions in the case of non-fast-forward updates.
1019            yield '\n'
1020            yield 'Summary of changes:\n'
1021            for line in read_git_lines(
1022                ['diff-tree']
1023                + self.diffopts
1024                + ['%s..%s' % (self.old.commit_sha1, self.new.commit_sha1,)],
1025                keepends=True,
1026                ):
1027                yield line
1028
1029        elif self.old.commit_sha1 and not self.new.commit_sha1:
1030            # A reference was deleted.  List the revisions that were
1031            # removed from the repository by this reference change.
1032
1033            sha1s = list(push.get_discarded_commits(self))
1034            tot = len(sha1s)
1035            discarded_revisions = [
1036                Revision(self, GitObject(sha1), num=i+1, tot=tot)
1037                for (i, sha1) in enumerate(sha1s)
1038                ]
1039
1040            if discarded_revisions:
1041                for line in self.expand_lines(DISCARDED_REVISIONS_TEMPLATE):
1042                    yield line
1043                yield '\n'
1044                for r in discarded_revisions:
1045                    (sha1, subject) = r.rev.get_summary()
1046                    yield r.expand(
1047                        BRIEF_SUMMARY_TEMPLATE, action='discards', text=subject,
1048                        )
1049            else:
1050                for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE):
1051                    yield line
1052
1053        elif not self.old.commit_sha1 and not self.new.commit_sha1:
1054            for line in self.expand_lines(NON_COMMIT_UPDATE_TEMPLATE):
1055                yield line
1056
1057    def generate_create_summary(self, push):
1058        """Called for the creation of a reference."""
1059
1060        # This is a new reference and so oldrev is not valid
1061        (sha1, subject) = self.new.get_summary()
1062        yield self.expand(
1063            BRIEF_SUMMARY_TEMPLATE, action='at',
1064            rev_short=sha1, text=subject,
1065            )
1066        yield '\n'
1067
1068    def generate_update_summary(self, push):
1069        """Called for the change of a pre-existing branch."""
1070
1071        return iter([])
1072
1073    def generate_delete_summary(self, push):
1074        """Called for the deletion of any type of reference."""
1075
1076        (sha1, subject) = self.old.get_summary()
1077        yield self.expand(
1078            BRIEF_SUMMARY_TEMPLATE, action='was',
1079            rev_short=sha1, text=subject,
1080            )
1081        yield '\n'
1082
1083
1084class BranchChange(ReferenceChange):
1085    refname_type = 'branch'
1086
1087    def __init__(self, environment, refname, short_refname, old, new, rev):
1088        ReferenceChange.__init__(
1089            self, environment,
1090            refname=refname, short_refname=short_refname,
1091            old=old, new=new, rev=rev,
1092            )
1093        self.recipients = environment.get_refchange_recipients(self)
1094
1095
1096class AnnotatedTagChange(ReferenceChange):
1097    refname_type = 'annotated tag'
1098
1099    def __init__(self, environment, refname, short_refname, old, new, rev):
1100        ReferenceChange.__init__(
1101            self, environment,
1102            refname=refname, short_refname=short_refname,
1103            old=old, new=new, rev=rev,
1104            )
1105        self.recipients = environment.get_announce_recipients(self)
1106        self.show_shortlog = environment.announce_show_shortlog
1107
1108    ANNOTATED_TAG_FORMAT = (
1109        '%(*objectname)\n'
1110        '%(*objecttype)\n'
1111        '%(taggername)\n'
1112        '%(taggerdate)'
1113        )
1114
1115    def describe_tag(self, push):
1116        """Describe the new value of an annotated tag."""
1117
1118        # Use git for-each-ref to pull out the individual fields from
1119        # the tag
1120        [tagobject, tagtype, tagger, tagged] = read_git_lines(
1121            ['for-each-ref', '--format=%s' % (self.ANNOTATED_TAG_FORMAT,), self.refname],
1122            )
1123
1124        yield self.expand(
1125            BRIEF_SUMMARY_TEMPLATE, action='tagging',
1126            rev_short=tagobject, text='(%s)' % (tagtype,),
1127            )
1128        if tagtype == 'commit':
1129            # If the tagged object is a commit, then we assume this is a
1130            # release, and so we calculate which tag this tag is
1131            # replacing
1132            try:
1133                prevtag = read_git_output(['describe', '--abbrev=0', '%s^' % (self.new,)])
1134            except CommandError:
1135                prevtag = None
1136            if prevtag:
1137                yield '  replaces  %s\n' % (prevtag,)
1138        else:
1139            prevtag = None
1140            yield '    length  %s bytes\n' % (read_git_output(['cat-file', '-s', tagobject]),)
1141
1142        yield ' tagged by  %s\n' % (tagger,)
1143        yield '        on  %s\n' % (tagged,)
1144        yield '\n'
1145
1146        # Show the content of the tag message; this might contain a
1147        # change log or release notes so is worth displaying.
1148        yield LOGBEGIN
1149        contents = list(read_git_lines(['cat-file', 'tag', self.new.sha1], keepends=True))
1150        contents = contents[contents.index('\n') + 1:]
1151        if contents and contents[-1][-1:] != '\n':
1152            contents.append('\n')
1153        for line in contents:
1154            yield line
1155
1156        if self.show_shortlog and tagtype == 'commit':
1157            # Only commit tags make sense to have rev-list operations
1158            # performed on them
1159            yield '\n'
1160            if prevtag:
1161                # Show changes since the previous release
1162                revlist = read_git_output(
1163                    ['rev-list', '--pretty=short', '%s..%s' % (prevtag, self.new,)],
1164                    keepends=True,
1165                    )
1166            else:
1167                # No previous tag, show all the changes since time
1168                # began
1169                revlist = read_git_output(
1170                    ['rev-list', '--pretty=short', '%s' % (self.new,)],
1171                    keepends=True,
1172                    )
1173            for line in read_git_lines(['shortlog'], input=revlist, keepends=True):
1174                yield line
1175
1176        yield LOGEND
1177        yield '\n'
1178
1179    def generate_create_summary(self, push):
1180        """Called for the creation of an annotated tag."""
1181
1182        for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1183            yield line
1184
1185        for line in self.describe_tag(push):
1186            yield line
1187
1188    def generate_update_summary(self, push):
1189        """Called for the update of an annotated tag.
1190
1191        This is probably a rare event and may not even be allowed."""
1192
1193        for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1194            yield line
1195
1196        for line in self.describe_tag(push):
1197            yield line
1198
1199    def generate_delete_summary(self, push):
1200        """Called when a non-annotated reference is updated."""
1201
1202        for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1203            yield line
1204
1205        yield self.expand('   tag was  %(oldrev_short)s\n')
1206        yield '\n'
1207
1208
1209class NonAnnotatedTagChange(ReferenceChange):
1210    refname_type = 'tag'
1211
1212    def __init__(self, environment, refname, short_refname, old, new, rev):
1213        ReferenceChange.__init__(
1214            self, environment,
1215            refname=refname, short_refname=short_refname,
1216            old=old, new=new, rev=rev,
1217            )
1218        self.recipients = environment.get_refchange_recipients(self)
1219
1220    def generate_create_summary(self, push):
1221        """Called for the creation of an annotated tag."""
1222
1223        for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1224            yield line
1225
1226    def generate_update_summary(self, push):
1227        """Called when a non-annotated reference is updated."""
1228
1229        for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1230            yield line
1231
1232    def generate_delete_summary(self, push):
1233        """Called when a non-annotated reference is updated."""
1234
1235        for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1236            yield line
1237
1238        for line in ReferenceChange.generate_delete_summary(self, push):
1239            yield line
1240
1241
1242class OtherReferenceChange(ReferenceChange):
1243    refname_type = 'reference'
1244
1245    def __init__(self, environment, refname, short_refname, old, new, rev):
1246        # We use the full refname as short_refname, because otherwise
1247        # the full name of the reference would not be obvious from the
1248        # text of the email.
1249        ReferenceChange.__init__(
1250            self, environment,
1251            refname=refname, short_refname=refname,
1252            old=old, new=new, rev=rev,
1253            )
1254        self.recipients = environment.get_refchange_recipients(self)
1255
1256
1257class Mailer(object):
1258    """An object that can send emails."""
1259
1260    def send(self, lines, to_addrs):
1261        """Send an email consisting of lines.
1262
1263        lines must be an iterable over the lines constituting the
1264        header and body of the email.  to_addrs is a list of recipient
1265        addresses (can be needed even if lines already contains a
1266        "To:" field).  It can be either a string (comma-separated list
1267        of email addresses) or a Python list of individual email
1268        addresses.
1269
1270        """
1271
1272        raise NotImplementedError()
1273
1274
1275class SendMailer(Mailer):
1276    """Send emails using 'sendmail -t'."""
1277
1278    SENDMAIL_CANDIDATES = [
1279        '/usr/sbin/sendmail',
1280        '/usr/lib/sendmail',
1281        ]
1282
1283    @staticmethod
1284    def find_sendmail():
1285        for path in SendMailer.SENDMAIL_CANDIDATES:
1286            if os.access(path, os.X_OK):
1287                return path
1288        else:
1289            raise ConfigurationException(
1290                'No sendmail executable found.  '
1291                'Try setting multimailhook.sendmailCommand.'
1292                )
1293
1294    def __init__(self, command=None, envelopesender=None):
1295        """Construct a SendMailer instance.
1296
1297        command should be the command and arguments used to invoke
1298        sendmail, as a list of strings.  If an envelopesender is
1299        provided, it will also be passed to the command, via '-f
1300        envelopesender'."""
1301
1302        if command:
1303            self.command = command[:]
1304        else:
1305            self.command = [self.find_sendmail(), '-t']
1306
1307        if envelopesender:
1308            self.command.extend(['-f', envelopesender])
1309
1310    def send(self, lines, to_addrs):
1311        try:
1312            p = subprocess.Popen(self.command, stdin=subprocess.PIPE)
1313        except OSError, e:
1314            sys.stderr.write(
1315                '*** Cannot execute command: %s\n' % ' '.join(self.command)
1316                + '*** %s\n' % str(e)
1317                + '*** Try setting multimailhook.mailer to "smtp"\n'
1318                '*** to send emails without using the sendmail command.\n'
1319                )
1320            sys.exit(1)
1321        try:
1322            p.stdin.writelines(lines)
1323        except:
1324            sys.stderr.write(
1325                '*** Error while generating commit email\n'
1326                '***  - mail sending aborted.\n'
1327                )
1328            p.terminate()
1329            raise
1330        else:
1331            p.stdin.close()
1332            retcode = p.wait()
1333            if retcode:
1334                raise CommandError(self.command, retcode)
1335
1336
1337class SMTPMailer(Mailer):
1338    """Send emails using Python's smtplib."""
1339
1340    def __init__(self, envelopesender, smtpserver):
1341        if not envelopesender:
1342            sys.stderr.write(
1343                'fatal: git_multimail: cannot use SMTPMailer without a sender address.\n'
1344                'please set either multimailhook.envelopeSender or user.email\n'
1345                )
1346            sys.exit(1)
1347        self.envelopesender = envelopesender
1348        self.smtpserver = smtpserver
1349        try:
1350            self.smtp = smtplib.SMTP(self.smtpserver)
1351        except Exception, e:
1352            sys.stderr.write('*** Error establishing SMTP connection to %s***\n' % self.smtpserver)
1353            sys.stderr.write('*** %s\n' % str(e))
1354            sys.exit(1)
1355
1356    def __del__(self):
1357        self.smtp.quit()
1358
1359    def send(self, lines, to_addrs):
1360        try:
1361            msg = ''.join(lines)
1362            # turn comma-separated list into Python list if needed.
1363            if isinstance(to_addrs, basestring):
1364                to_addrs = [email for (name, email) in getaddresses([to_addrs])]
1365            self.smtp.sendmail(self.envelopesender, to_addrs, msg)
1366        except Exception, e:
1367            sys.stderr.write('*** Error sending email***\n')
1368            sys.stderr.write('*** %s\n' % str(e))
1369            self.smtp.quit()
1370            sys.exit(1)
1371
1372
1373class OutputMailer(Mailer):
1374    """Write emails to an output stream, bracketed by lines of '=' characters.
1375
1376    This is intended for debugging purposes."""
1377
1378    SEPARATOR = '=' * 75 + '\n'
1379
1380    def __init__(self, f):
1381        self.f = f
1382
1383    def send(self, lines, to_addrs):
1384        self.f.write(self.SEPARATOR)
1385        self.f.writelines(lines)
1386        self.f.write(self.SEPARATOR)
1387
1388
1389def get_git_dir():
1390    """Determine GIT_DIR.
1391
1392    Determine GIT_DIR either from the GIT_DIR environment variable or
1393    from the working directory, using Git's usual rules."""
1394
1395    try:
1396        return read_git_output(['rev-parse', '--git-dir'])
1397    except CommandError:
1398        sys.stderr.write('fatal: git_multimail: not in a git directory\n')
1399        sys.exit(1)
1400
1401
1402class Environment(object):
1403    """Describes the environment in which the push is occurring.
1404
1405    An Environment object encapsulates information about the local
1406    environment.  For example, it knows how to determine:
1407
1408    * the name of the repository to which the push occurred
1409
1410    * what user did the push
1411
1412    * what users want to be informed about various types of changes.
1413
1414    An Environment object is expected to have the following methods:
1415
1416        get_repo_shortname()
1417
1418            Return a short name for the repository, for display
1419            purposes.
1420
1421        get_repo_path()
1422
1423            Return the absolute path to the Git repository.
1424
1425        get_emailprefix()
1426
1427            Return a string that will be prefixed to every email's
1428            subject.
1429
1430        get_pusher()
1431
1432            Return the username of the person who pushed the changes.
1433            This value is used in the email body to indicate who
1434            pushed the change.
1435
1436        get_pusher_email() (may return None)
1437
1438            Return the email address of the person who pushed the
1439            changes.  The value should be a single RFC 2822 email
1440            address as a string; e.g., "Joe User <user@example.com>"
1441            if available, otherwise "user@example.com".  If set, the
1442            value is used as the Reply-To address for refchange
1443            emails.  If it is impossible to determine the pusher's
1444            email, this attribute should be set to None (in which case
1445            no Reply-To header will be output).
1446
1447        get_sender()
1448
1449            Return the address to be used as the 'From' email address
1450            in the email envelope.
1451
1452        get_fromaddr()
1453
1454            Return the 'From' email address used in the email 'From:'
1455            headers.  (May be a full RFC 2822 email address like 'Joe
1456            User <user@example.com>'.)
1457
1458        get_administrator()
1459
1460            Return the name and/or email of the repository
1461            administrator.  This value is used in the footer as the
1462            person to whom requests to be removed from the
1463            notification list should be sent.  Ideally, it should
1464            include a valid email address.
1465
1466        get_reply_to_refchange()
1467        get_reply_to_commit()
1468
1469            Return the address to use in the email "Reply-To" header,
1470            as a string.  These can be an RFC 2822 email address, or
1471            None to omit the "Reply-To" header.
1472            get_reply_to_refchange() is used for refchange emails;
1473            get_reply_to_commit() is used for individual commit
1474            emails.
1475
1476    They should also define the following attributes:
1477
1478        announce_show_shortlog (bool)
1479
1480            True iff announce emails should include a shortlog.
1481
1482        refchange_showlog (bool)
1483
1484            True iff refchanges emails should include a detailed log.
1485
1486        diffopts (list of strings)
1487
1488            The options that should be passed to 'git diff' for the
1489            summary email.  The value should be a list of strings
1490            representing words to be passed to the command.
1491
1492        logopts (list of strings)
1493
1494            Analogous to diffopts, but contains options passed to
1495            'git log' when generating the detailed log for a set of
1496            commits (see refchange_showlog)
1497
1498    """
1499
1500    REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$')
1501
1502    def __init__(self, osenv=None):
1503        self.osenv = osenv or os.environ
1504        self.announce_show_shortlog = False
1505        self.maxcommitemails = 500
1506        self.diffopts = ['--stat', '--summary', '--find-copies-harder']
1507        self.logopts = []
1508        self.refchange_showlog = False
1509
1510        self.COMPUTED_KEYS = [
1511            'administrator',
1512            'charset',
1513            'emailprefix',
1514            'fromaddr',
1515            'pusher',
1516            'pusher_email',
1517            'repo_path',
1518            'repo_shortname',
1519            'sender',
1520            ]
1521
1522        self._values = None
1523
1524    def get_repo_shortname(self):
1525        """Use the last part of the repo path, with ".git" stripped off if present."""
1526
1527        basename = os.path.basename(os.path.abspath(self.get_repo_path()))
1528        m = self.REPO_NAME_RE.match(basename)
1529        if m:
1530            return m.group('name')
1531        else:
1532            return basename
1533
1534    def get_pusher(self):
1535        raise NotImplementedError()
1536
1537    def get_pusher_email(self):
1538        return None
1539
1540    def get_administrator(self):
1541        return 'the administrator of this repository'
1542
1543    def get_emailprefix(self):
1544        return ''
1545
1546    def get_repo_path(self):
1547        if read_git_output(['rev-parse', '--is-bare-repository']) == 'true':
1548            path = get_git_dir()
1549        else:
1550            path = read_git_output(['rev-parse', '--show-toplevel'])
1551        return os.path.abspath(path)
1552
1553    def get_charset(self):
1554        return CHARSET
1555
1556    def get_values(self):
1557        """Return a dictionary {keyword : expansion} for this Environment.
1558
1559        This method is called by Change._compute_values().  The keys
1560        in the returned dictionary are available to be used in any of
1561        the templates.  The dictionary is created by calling
1562        self.get_NAME() for each of the attributes named in
1563        COMPUTED_KEYS and recording those that do not return None.
1564        The return value is always a new dictionary."""
1565
1566        if self._values is None:
1567            values = {}
1568
1569            for key in self.COMPUTED_KEYS:
1570                value = getattr(self, 'get_%s' % (key,))()
1571                if value is not None:
1572                    values[key] = value
1573
1574            self._values = values
1575
1576        return self._values.copy()
1577
1578    def get_refchange_recipients(self, refchange):
1579        """Return the recipients for notifications about refchange.
1580
1581        Return the list of email addresses to which notifications
1582        about the specified ReferenceChange should be sent."""
1583
1584        raise NotImplementedError()
1585
1586    def get_announce_recipients(self, annotated_tag_change):
1587        """Return the recipients for notifications about annotated_tag_change.
1588
1589        Return the list of email addresses to which notifications
1590        about the specified AnnotatedTagChange should be sent."""
1591
1592        raise NotImplementedError()
1593
1594    def get_reply_to_refchange(self, refchange):
1595        return self.get_pusher_email()
1596
1597    def get_revision_recipients(self, revision):
1598        """Return the recipients for messages about revision.
1599
1600        Return the list of email addresses to which notifications
1601        about the specified Revision should be sent.  This method
1602        could be overridden, for example, to take into account the
1603        contents of the revision when deciding whom to notify about
1604        it.  For example, there could be a scheme for users to express
1605        interest in particular files or subdirectories, and only
1606        receive notification emails for revisions that affecting those
1607        files."""
1608
1609        raise NotImplementedError()
1610
1611    def get_reply_to_commit(self, revision):
1612        return revision.author
1613
1614    def filter_body(self, lines):
1615        """Filter the lines intended for an email body.
1616
1617        lines is an iterable over the lines that would go into the
1618        email body.  Filter it (e.g., limit the number of lines, the
1619        line length, character set, etc.), returning another iterable.
1620        See FilterLinesEnvironmentMixin and MaxlinesEnvironmentMixin
1621        for classes implementing this functionality."""
1622
1623        return lines
1624
1625
1626class ConfigEnvironmentMixin(Environment):
1627    """A mixin that sets self.config to its constructor's config argument.
1628
1629    This class's constructor consumes the "config" argument.
1630
1631    Mixins that need to inspect the config should inherit from this
1632    class (1) to make sure that "config" is still in the constructor
1633    arguments with its own constructor runs and/or (2) to be sure that
1634    self.config is set after construction."""
1635
1636    def __init__(self, config, **kw):
1637        super(ConfigEnvironmentMixin, self).__init__(**kw)
1638        self.config = config
1639
1640
1641class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):
1642    """An Environment that reads most of its information from "git config"."""
1643
1644    def __init__(self, config, **kw):
1645        super(ConfigOptionsEnvironmentMixin, self).__init__(
1646            config=config, **kw
1647            )
1648
1649        self.announce_show_shortlog = config.get_bool(
1650            'announceshortlog', default=self.announce_show_shortlog
1651            )
1652
1653        self.refchange_showlog = config.get_bool(
1654            'refchangeshowlog', default=self.refchange_showlog
1655            )
1656
1657        maxcommitemails = config.get('maxcommitemails')
1658        if maxcommitemails is not None:
1659            try:
1660                self.maxcommitemails = int(maxcommitemails)
1661            except ValueError:
1662                sys.stderr.write(
1663                    '*** Malformed value for multimailhook.maxCommitEmails: %s\n' % maxcommitemails
1664                    + '*** Expected a number.  Ignoring.\n'
1665                    )
1666
1667        diffopts = config.get('diffopts')
1668        if diffopts is not None:
1669            self.diffopts = shlex.split(diffopts)
1670
1671        logopts = config.get('logopts')
1672        if logopts is not None:
1673            self.logopts = shlex.split(logopts)
1674
1675        reply_to = config.get('replyTo')
1676        self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to)
1677        if (
1678            self.__reply_to_refchange is not None
1679            and self.__reply_to_refchange.lower() == 'author'
1680            ):
1681            raise ConfigurationException(
1682                '"author" is not an allowed setting for replyToRefchange'
1683                )
1684        self.__reply_to_commit = config.get('replyToCommit', default=reply_to)
1685
1686    def get_administrator(self):
1687        return (
1688            self.config.get('administrator')
1689            or self.get_sender()
1690            or super(ConfigOptionsEnvironmentMixin, self).get_administrator()
1691            )
1692
1693    def get_repo_shortname(self):
1694        return (
1695            self.config.get('reponame')
1696            or super(ConfigOptionsEnvironmentMixin, self).get_repo_shortname()
1697            )
1698
1699    def get_emailprefix(self):
1700        emailprefix = self.config.get('emailprefix')
1701        if emailprefix and emailprefix.strip():
1702            return emailprefix.strip() + ' '
1703        else:
1704            return '[%s] ' % (self.get_repo_shortname(),)
1705
1706    def get_sender(self):
1707        return self.config.get('envelopesender')
1708
1709    def get_fromaddr(self):
1710        fromaddr = self.config.get('from')
1711        if fromaddr:
1712            return fromaddr
1713        else:
1714            config = Config('user')
1715            fromname = config.get('name', default='')
1716            fromemail = config.get('email', default='')
1717            if fromemail:
1718                return formataddr([fromname, fromemail])
1719            else:
1720                return self.get_sender()
1721
1722    def get_reply_to_refchange(self, refchange):
1723        if self.__reply_to_refchange is None:
1724            return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_refchange(refchange)
1725        elif self.__reply_to_refchange.lower() == 'pusher':
1726            return self.get_pusher_email()
1727        elif self.__reply_to_refchange.lower() == 'none':
1728            return None
1729        else:
1730            return self.__reply_to_refchange
1731
1732    def get_reply_to_commit(self, revision):
1733        if self.__reply_to_commit is None:
1734            return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_commit(revision)
1735        elif self.__reply_to_commit.lower() == 'author':
1736            return revision.get_author()
1737        elif self.__reply_to_commit.lower() == 'pusher':
1738            return self.get_pusher_email()
1739        elif self.__reply_to_commit.lower() == 'none':
1740            return None
1741        else:
1742            return self.__reply_to_commit
1743
1744
1745class FilterLinesEnvironmentMixin(Environment):
1746    """Handle encoding and maximum line length of body lines.
1747
1748        emailmaxlinelength (int or None)
1749
1750            The maximum length of any single line in the email body.
1751            Longer lines are truncated at that length with ' [...]'
1752            appended.
1753
1754        strict_utf8 (bool)
1755
1756            If this field is set to True, then the email body text is
1757            expected to be UTF-8.  Any invalid characters are
1758            converted to U+FFFD, the Unicode replacement character
1759            (encoded as UTF-8, of course).
1760
1761    """
1762
1763    def __init__(self, strict_utf8=True, emailmaxlinelength=500, **kw):
1764        super(FilterLinesEnvironmentMixin, self).__init__(**kw)
1765        self.__strict_utf8 = strict_utf8
1766        self.__emailmaxlinelength = emailmaxlinelength
1767
1768    def filter_body(self, lines):
1769        lines = super(FilterLinesEnvironmentMixin, self).filter_body(lines)
1770        if self.__strict_utf8:
1771            lines = (line.decode(ENCODING, 'replace') for line in lines)
1772            # Limit the line length in Unicode-space to avoid
1773            # splitting characters:
1774            if self.__emailmaxlinelength:
1775                lines = limit_linelength(lines, self.__emailmaxlinelength)
1776            lines = (line.encode(ENCODING, 'replace') for line in lines)
1777        elif self.__emailmaxlinelength:
1778            lines = limit_linelength(lines, self.__emailmaxlinelength)
1779
1780        return lines
1781
1782
1783class ConfigFilterLinesEnvironmentMixin(
1784    ConfigEnvironmentMixin,
1785    FilterLinesEnvironmentMixin,
1786    ):
1787    """Handle encoding and maximum line length based on config."""
1788
1789    def __init__(self, config, **kw):
1790        strict_utf8 = config.get_bool('emailstrictutf8', default=None)
1791        if strict_utf8 is not None:
1792            kw['strict_utf8'] = strict_utf8
1793
1794        emailmaxlinelength = config.get('emailmaxlinelength')
1795        if emailmaxlinelength is not None:
1796            kw['emailmaxlinelength'] = int(emailmaxlinelength)
1797
1798        super(ConfigFilterLinesEnvironmentMixin, self).__init__(
1799            config=config, **kw
1800            )
1801
1802
1803class MaxlinesEnvironmentMixin(Environment):
1804    """Limit the email body to a specified number of lines."""
1805
1806    def __init__(self, emailmaxlines, **kw):
1807        super(MaxlinesEnvironmentMixin, self).__init__(**kw)
1808        self.__emailmaxlines = emailmaxlines
1809
1810    def filter_body(self, lines):
1811        lines = super(MaxlinesEnvironmentMixin, self).filter_body(lines)
1812        if self.__emailmaxlines:
1813            lines = limit_lines(lines, self.__emailmaxlines)
1814        return lines
1815
1816
1817class ConfigMaxlinesEnvironmentMixin(
1818    ConfigEnvironmentMixin,
1819    MaxlinesEnvironmentMixin,
1820    ):
1821    """Limit the email body to the number of lines specified in config."""
1822
1823    def __init__(self, config, **kw):
1824        emailmaxlines = int(config.get('emailmaxlines', default='0'))
1825        super(ConfigMaxlinesEnvironmentMixin, self).__init__(
1826            config=config,
1827            emailmaxlines=emailmaxlines,
1828            **kw
1829            )
1830
1831
1832class PusherDomainEnvironmentMixin(ConfigEnvironmentMixin):
1833    """Deduce pusher_email from pusher by appending an emaildomain."""
1834
1835    def __init__(self, **kw):
1836        super(PusherDomainEnvironmentMixin, self).__init__(**kw)
1837        self.__emaildomain = self.config.get('emaildomain')
1838
1839    def get_pusher_email(self):
1840        if self.__emaildomain:
1841            # Derive the pusher's full email address in the default way:
1842            return '%s@%s' % (self.get_pusher(), self.__emaildomain)
1843        else:
1844            return super(PusherDomainEnvironmentMixin, self).get_pusher_email()
1845
1846
1847class StaticRecipientsEnvironmentMixin(Environment):
1848    """Set recipients statically based on constructor parameters."""
1849
1850    def __init__(
1851        self,
1852        refchange_recipients, announce_recipients, revision_recipients,
1853        **kw
1854        ):
1855        super(StaticRecipientsEnvironmentMixin, self).__init__(**kw)
1856
1857        # The recipients for various types of notification emails, as
1858        # RFC 2822 email addresses separated by commas (or the empty
1859        # string if no recipients are configured).  Although there is
1860        # a mechanism to choose the recipient lists based on on the
1861        # actual *contents* of the change being reported, we only
1862        # choose based on the *type* of the change.  Therefore we can
1863        # compute them once and for all:
1864        self.__refchange_recipients = refchange_recipients
1865        self.__announce_recipients = announce_recipients
1866        self.__revision_recipients = revision_recipients
1867
1868    def get_refchange_recipients(self, refchange):
1869        return self.__refchange_recipients
1870
1871    def get_announce_recipients(self, annotated_tag_change):
1872        return self.__announce_recipients
1873
1874    def get_revision_recipients(self, revision):
1875        return self.__revision_recipients
1876
1877
1878class ConfigRecipientsEnvironmentMixin(
1879    ConfigEnvironmentMixin,
1880    StaticRecipientsEnvironmentMixin
1881    ):
1882    """Determine recipients statically based on config."""
1883
1884    def __init__(self, config, **kw):
1885        super(ConfigRecipientsEnvironmentMixin, self).__init__(
1886            config=config,
1887            refchange_recipients=self._get_recipients(
1888                config, 'refchangelist', 'mailinglist',
1889                ),
1890            announce_recipients=self._get_recipients(
1891                config, 'announcelist', 'refchangelist', 'mailinglist',
1892                ),
1893            revision_recipients=self._get_recipients(
1894                config, 'commitlist', 'mailinglist',
1895                ),
1896            **kw
1897            )
1898
1899    def _get_recipients(self, config, *names):
1900        """Return the recipients for a particular type of message.
1901
1902        Return the list of email addresses to which a particular type
1903        of notification email should be sent, by looking at the config
1904        value for "multimailhook.$name" for each of names.  Use the
1905        value from the first name that is configured.  The return
1906        value is a (possibly empty) string containing RFC 2822 email
1907        addresses separated by commas.  If no configuration could be
1908        found, raise a ConfigurationException."""
1909
1910        for name in names:
1911            retval = config.get_recipients(name)
1912            if retval is not None:
1913                return retval
1914        if len(names) == 1:
1915            hint = 'Please set "%s.%s"' % (config.section, name)
1916        else:
1917            hint = (
1918                'Please set one of the following:\n    "%s"'
1919                % ('"\n    "'.join('%s.%s' % (config.section, name) for name in names))
1920                )
1921
1922        raise ConfigurationException(
1923            'The list of recipients for %s is not configured.\n%s' % (names[0], hint)
1924            )
1925
1926
1927class ProjectdescEnvironmentMixin(Environment):
1928    """Make a "projectdesc" value available for templates.
1929
1930    By default, it is set to the first line of $GIT_DIR/description
1931    (if that file is present and appears to be set meaningfully)."""
1932
1933    def __init__(self, **kw):
1934        super(ProjectdescEnvironmentMixin, self).__init__(**kw)
1935        self.COMPUTED_KEYS += ['projectdesc']
1936
1937    def get_projectdesc(self):
1938        """Return a one-line descripition of the project."""
1939
1940        git_dir = get_git_dir()
1941        try:
1942            projectdesc = open(os.path.join(git_dir, 'description')).readline().strip()
1943            if projectdesc and not projectdesc.startswith('Unnamed repository'):
1944                return projectdesc
1945        except IOError:
1946            pass
1947
1948        return 'UNNAMED PROJECT'
1949
1950
1951class GenericEnvironmentMixin(Environment):
1952    def get_pusher(self):
1953        return self.osenv.get('USER', 'unknown user')
1954
1955
1956class GenericEnvironment(
1957    ProjectdescEnvironmentMixin,
1958    ConfigMaxlinesEnvironmentMixin,
1959    ConfigFilterLinesEnvironmentMixin,
1960    ConfigRecipientsEnvironmentMixin,
1961    PusherDomainEnvironmentMixin,
1962    ConfigOptionsEnvironmentMixin,
1963    GenericEnvironmentMixin,
1964    Environment,
1965    ):
1966    pass
1967
1968
1969class GitoliteEnvironmentMixin(Environment):
1970    def get_repo_shortname(self):
1971        # The gitolite environment variable $GL_REPO is a pretty good
1972        # repo_shortname (though it's probably not as good as a value
1973        # the user might have explicitly put in his config).
1974        return (
1975            self.osenv.get('GL_REPO', None)
1976            or super(GitoliteEnvironmentMixin, self).get_repo_shortname()
1977            )
1978
1979    def get_pusher(self):
1980        return self.osenv.get('GL_USER', 'unknown user')
1981
1982
1983class GitoliteEnvironment(
1984    ProjectdescEnvironmentMixin,
1985    ConfigMaxlinesEnvironmentMixin,
1986    ConfigFilterLinesEnvironmentMixin,
1987    ConfigRecipientsEnvironmentMixin,
1988    PusherDomainEnvironmentMixin,
1989    ConfigOptionsEnvironmentMixin,
1990    GitoliteEnvironmentMixin,
1991    Environment,
1992    ):
1993    pass
1994
1995
1996class Push(object):
1997    """Represent an entire push (i.e., a group of ReferenceChanges).
1998
1999    It is easy to figure out what commits were added to a *branch* by
2000    a Reference change:
2001
2002        git rev-list change.old..change.new
2003
2004    or removed from a *branch*:
2005
2006        git rev-list change.new..change.old
2007
2008    But it is not quite so trivial to determine which entirely new
2009    commits were added to the *repository* by a push and which old
2010    commits were discarded by a push.  A big part of the job of this
2011    class is to figure out these things, and to make sure that new
2012    commits are only detailed once even if they were added to multiple
2013    references.
2014
2015    The first step is to determine the "other" references--those
2016    unaffected by the current push.  They are computed by
2017    Push._compute_other_ref_sha1s() by listing all references then
2018    removing any affected by this push.
2019
2020    The commits contained in the repository before this push were
2021
2022        git rev-list other1 other2 other3 ... change1.old change2.old ...
2023
2024    Where "changeN.old" is the old value of one of the references
2025    affected by this push.
2026
2027    The commits contained in the repository after this push are
2028
2029        git rev-list other1 other2 other3 ... change1.new change2.new ...
2030
2031    The commits added by this push are the difference between these
2032    two sets, which can be written
2033
2034        git rev-list \
2035            ^other1 ^other2 ... \
2036            ^change1.old ^change2.old ... \
2037            change1.new change2.new ...
2038
2039    The commits removed by this push can be computed by
2040
2041        git rev-list \
2042            ^other1 ^other2 ... \
2043            ^change1.new ^change2.new ... \
2044            change1.old change2.old ...
2045
2046    The last point is that it is possible that other pushes are
2047    occurring simultaneously to this one, so reference values can
2048    change at any time.  It is impossible to eliminate all race
2049    conditions, but we reduce the window of time during which problems
2050    can occur by translating reference names to SHA1s as soon as
2051    possible and working with SHA1s thereafter (because SHA1s are
2052    immutable)."""
2053
2054    # A map {(changeclass, changetype) : integer} specifying the order
2055    # that reference changes will be processed if multiple reference
2056    # changes are included in a single push.  The order is significant
2057    # mostly because new commit notifications are threaded together
2058    # with the first reference change that includes the commit.  The
2059    # following order thus causes commits to be grouped with branch
2060    # changes (as opposed to tag changes) if possible.
2061    SORT_ORDER = dict(
2062        (value, i) for (i, value) in enumerate([
2063            (BranchChange, 'update'),
2064            (BranchChange, 'create'),
2065            (AnnotatedTagChange, 'update'),
2066            (AnnotatedTagChange, 'create'),
2067            (NonAnnotatedTagChange, 'update'),
2068            (NonAnnotatedTagChange, 'create'),
2069            (BranchChange, 'delete'),
2070            (AnnotatedTagChange, 'delete'),
2071            (NonAnnotatedTagChange, 'delete'),
2072            (OtherReferenceChange, 'update'),
2073            (OtherReferenceChange, 'create'),
2074            (OtherReferenceChange, 'delete'),
2075            ])
2076        )
2077
2078    def __init__(self, changes):
2079        self.changes = sorted(changes, key=self._sort_key)
2080
2081        # The SHA-1s of commits referred to by references unaffected
2082        # by this push:
2083        other_ref_sha1s = self._compute_other_ref_sha1s()
2084
2085        self._old_rev_exclusion_spec = self._compute_rev_exclusion_spec(
2086            other_ref_sha1s.union(
2087                change.old.sha1
2088                for change in self.changes
2089                if change.old.type in ['commit', 'tag']
2090                )
2091            )
2092        self._new_rev_exclusion_spec = self._compute_rev_exclusion_spec(
2093            other_ref_sha1s.union(
2094                change.new.sha1
2095                for change in self.changes
2096                if change.new.type in ['commit', 'tag']
2097                )
2098            )
2099
2100    @classmethod
2101    def _sort_key(klass, change):
2102        return (klass.SORT_ORDER[change.__class__, change.change_type], change.refname,)
2103
2104    def _compute_other_ref_sha1s(self):
2105        """Return the GitObjects referred to by references unaffected by this push."""
2106
2107        # The refnames being changed by this push:
2108        updated_refs = set(
2109            change.refname
2110            for change in self.changes
2111            )
2112
2113        # The SHA-1s of commits referred to by all references in this
2114        # repository *except* updated_refs:
2115        sha1s = set()
2116        fmt = (
2117            '%(objectname) %(objecttype) %(refname)\n'
2118            '%(*objectname) %(*objecttype) %(refname)'
2119            )
2120        for line in read_git_lines(['for-each-ref', '--format=%s' % (fmt,)]):
2121            (sha1, type, name) = line.split(' ', 2)
2122            if sha1 and type == 'commit' and name not in updated_refs:
2123                sha1s.add(sha1)
2124
2125        return sha1s
2126
2127    def _compute_rev_exclusion_spec(self, sha1s):
2128        """Return an exclusion specification for 'git rev-list'.
2129
2130        git_objects is an iterable over GitObject instances.  Return a
2131        string that can be passed to the standard input of 'git
2132        rev-list --stdin' to exclude all of the commits referred to by
2133        git_objects."""
2134
2135        return ''.join(
2136            ['^%s\n' % (sha1,) for sha1 in sorted(sha1s)]
2137            )
2138
2139    def get_new_commits(self, reference_change=None):
2140        """Return a list of commits added by this push.
2141
2142        Return a list of the object names of commits that were added
2143        by the part of this push represented by reference_change.  If
2144        reference_change is None, then return a list of *all* commits
2145        added by this push."""
2146
2147        if not reference_change:
2148            new_revs = sorted(
2149                change.new.sha1
2150                for change in self.changes
2151                if change.new
2152                )
2153        elif not reference_change.new.commit_sha1:
2154            return []
2155        else:
2156            new_revs = [reference_change.new.commit_sha1]
2157
2158        cmd = ['rev-list', '--stdin'] + new_revs
2159        return read_git_lines(cmd, input=self._old_rev_exclusion_spec)
2160
2161    def get_discarded_commits(self, reference_change):
2162        """Return a list of commits discarded by this push.
2163
2164        Return a list of the object names of commits that were
2165        entirely discarded from the repository by the part of this
2166        push represented by reference_change."""
2167
2168        if not reference_change.old.commit_sha1:
2169            return []
2170        else:
2171            old_revs = [reference_change.old.commit_sha1]
2172
2173        cmd = ['rev-list', '--stdin'] + old_revs
2174        return read_git_lines(cmd, input=self._new_rev_exclusion_spec)
2175
2176    def send_emails(self, mailer, body_filter=None):
2177        """Use send all of the notification emails needed for this push.
2178
2179        Use send all of the notification emails (including reference
2180        change emails and commit emails) needed for this push.  Send
2181        the emails using mailer.  If body_filter is not None, then use
2182        it to filter the lines that are intended for the email
2183        body."""
2184
2185        # The sha1s of commits that were introduced by this push.
2186        # They will be removed from this set as they are processed, to
2187        # guarantee that one (and only one) email is generated for
2188        # each new commit.
2189        unhandled_sha1s = set(self.get_new_commits())
2190        for change in self.changes:
2191            # Check if we've got anyone to send to
2192            if not change.recipients:
2193                sys.stderr.write(
2194                    '*** no recipients configured so no email will be sent\n'
2195                    '*** for %r update %s->%s\n'
2196                    % (change.refname, change.old.sha1, change.new.sha1,)
2197                    )
2198            else:
2199                sys.stderr.write('Sending notification emails to: %s\n' % (change.recipients,))
2200                mailer.send(change.generate_email(self, body_filter), change.recipients)
2201
2202            sha1s = []
2203            for sha1 in reversed(list(self.get_new_commits(change))):
2204                if sha1 in unhandled_sha1s:
2205                    sha1s.append(sha1)
2206                    unhandled_sha1s.remove(sha1)
2207
2208            max_emails = change.environment.maxcommitemails
2209            if max_emails and len(sha1s) > max_emails:
2210                sys.stderr.write(
2211                    '*** Too many new commits (%d), not sending commit emails.\n' % len(sha1s)
2212                    + '*** Try setting multimailhook.maxCommitEmails to a greater value\n'
2213                    + '*** Currently, multimailhook.maxCommitEmails=%d\n' % max_emails
2214                    )
2215                return
2216
2217            for (num, sha1) in enumerate(sha1s):
2218                rev = Revision(change, GitObject(sha1), num=num+1, tot=len(sha1s))
2219                if rev.recipients:
2220                    mailer.send(rev.generate_email(self, body_filter), rev.recipients)
2221
2222        # Consistency check:
2223        if unhandled_sha1s:
2224            sys.stderr.write(
2225                'ERROR: No emails were sent for the following new commits:\n'
2226                '    %s\n'
2227                % ('\n    '.join(sorted(unhandled_sha1s)),)
2228                )
2229
2230
2231def run_as_post_receive_hook(environment, mailer):
2232    changes = []
2233    for line in sys.stdin:
2234        (oldrev, newrev, refname) = line.strip().split(' ', 2)
2235        changes.append(
2236            ReferenceChange.create(environment, oldrev, newrev, refname)
2237            )
2238    push = Push(changes)
2239    push.send_emails(mailer, body_filter=environment.filter_body)
2240
2241
2242def run_as_update_hook(environment, mailer, refname, oldrev, newrev):
2243    changes = [
2244        ReferenceChange.create(
2245            environment,
2246            read_git_output(['rev-parse', '--verify', oldrev]),
2247            read_git_output(['rev-parse', '--verify', newrev]),
2248            refname,
2249            ),
2250        ]
2251    push = Push(changes)
2252    push.send_emails(mailer, body_filter=environment.filter_body)
2253
2254
2255def choose_mailer(config, environment):
2256    mailer = config.get('mailer', default='sendmail')
2257
2258    if mailer == 'smtp':
2259        smtpserver = config.get('smtpserver', default='localhost')
2260        mailer = SMTPMailer(
2261            envelopesender=(environment.get_sender() or environment.get_fromaddr()),
2262            smtpserver=smtpserver,
2263            )
2264    elif mailer == 'sendmail':
2265        command = config.get('sendmailcommand')
2266        if command:
2267            command = shlex.split(command)
2268        mailer = SendMailer(command=command, envelopesender=environment.get_sender())
2269    else:
2270        sys.stderr.write(
2271            'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n' % mailer
2272            + 'please use one of "smtp" or "sendmail".\n'
2273            )
2274        sys.exit(1)
2275    return mailer
2276
2277
2278KNOWN_ENVIRONMENTS = {
2279    'generic' : GenericEnvironmentMixin,
2280    'gitolite' : GitoliteEnvironmentMixin,
2281    }
2282
2283
2284def choose_environment(config, osenv=None, env=None, recipients=None):
2285    if not osenv:
2286        osenv = os.environ
2287
2288    environment_mixins = [
2289        ProjectdescEnvironmentMixin,
2290        ConfigMaxlinesEnvironmentMixin,
2291        ConfigFilterLinesEnvironmentMixin,
2292        PusherDomainEnvironmentMixin,
2293        ConfigOptionsEnvironmentMixin,
2294        ]
2295    environment_kw = {
2296        'osenv' : osenv,
2297        'config' : config,
2298        }
2299
2300    if not env:
2301        env = config.get('environment')
2302
2303    if not env:
2304        if 'GL_USER' in osenv and 'GL_REPO' in osenv:
2305            env = 'gitolite'
2306        else:
2307            env = 'generic'
2308
2309    environment_mixins.append(KNOWN_ENVIRONMENTS[env])
2310
2311    if recipients:
2312        environment_mixins.insert(0, StaticRecipientsEnvironmentMixin)
2313        environment_kw['refchange_recipients'] = recipients
2314        environment_kw['announce_recipients'] = recipients
2315        environment_kw['revision_recipients'] = recipients
2316    else:
2317        environment_mixins.insert(0, ConfigRecipientsEnvironmentMixin)
2318
2319    environment_klass = type(
2320        'EffectiveEnvironment',
2321        tuple(environment_mixins) + (Environment,),
2322        {},
2323        )
2324    return environment_klass(**environment_kw)
2325
2326
2327def main(args):
2328    parser = optparse.OptionParser(
2329        description=__doc__,
2330        usage='%prog [OPTIONS]\n   or: %prog [OPTIONS] REFNAME OLDREV NEWREV',
2331        )
2332
2333    parser.add_option(
2334        '--environment', '--env', action='store', type='choice',
2335        choices=['generic', 'gitolite'], default=None,
2336        help=(
2337            'Choose type of environment is in use.  Default is taken from '
2338            'multimailhook.environment if set; otherwise "generic".'
2339            ),
2340        )
2341    parser.add_option(
2342        '--stdout', action='store_true', default=False,
2343        help='Output emails to stdout rather than sending them.',
2344        )
2345    parser.add_option(
2346        '--recipients', action='store', default=None,
2347        help='Set list of email recipients for all types of emails.',
2348        )
2349    parser.add_option(
2350        '--show-env', action='store_true', default=False,
2351        help=(
2352            'Write to stderr the values determined for the environment '
2353            '(intended for debugging purposes).'
2354            ),
2355        )
2356
2357    (options, args) = parser.parse_args(args)
2358
2359    config = Config('multimailhook')
2360
2361    try:
2362        environment = choose_environment(
2363            config, osenv=os.environ,
2364            env=options.environment,
2365            recipients=options.recipients,
2366            )
2367
2368        if options.show_env:
2369            sys.stderr.write('Environment values:\n')
2370            for (k,v) in sorted(environment.get_values().items()):
2371                sys.stderr.write('    %s : %r\n' % (k,v))
2372            sys.stderr.write('\n')
2373
2374        if options.stdout:
2375            mailer = OutputMailer(sys.stdout)
2376        else:
2377            mailer = choose_mailer(config, environment)
2378
2379        # Dual mode: if arguments were specified on the command line, run
2380        # like an update hook; otherwise, run as a post-receive hook.
2381        if args:
2382            if len(args) != 3:
2383                parser.error('Need zero or three non-option arguments')
2384            (refname, oldrev, newrev) = args
2385            run_as_update_hook(environment, mailer, refname, oldrev, newrev)
2386        else:
2387            run_as_post_receive_hook(environment, mailer)
2388    except ConfigurationException, e:
2389        sys.exit(str(e))
2390
2391
2392if __name__ == '__main__':
2393    main(sys.argv[1:])