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