#246 git/hooks: Update the gnome-post-receive-email hook to use the git-multimail gook instead
Merged 3 years ago by kevin. Opened 3 years ago by pingou.
fedora-infra/ pingou/ansible update_email_hook  into  master

@@ -1,941 +1,4594 @@ 

- #!/usr/bin/python

- #

- # gnome-post-receive-email - Post receive email hook for the GNOME Git repository

+ #!/usr/bin/env python

+ 

+ __version__ = "1.4.0"

+ 

+ # Copyright (c) 2015-2016 Matthieu Moy and others

+ # Copyright (c) 2012-2014 Michael Haggerty and others

+ # Derived from contrib/hooks/post-receive-email, which is

+ # Copyright (c) 2007 Andy Parkins

+ # and also includes contributions by other authors.

  #

- # Copyright (C) 2008  Owen Taylor

- # Copyright (C) 2009  Red Hat, Inc

+ # This file is part of git-multimail.

  #

- # This program is free software; you can redistribute it and/or

- # modify it under the terms of the GNU General Public License

- # as published by the Free Software Foundation; either version 2

- # of the License, or (at your option) any later version.

+ # git-multimail is free software: you can redistribute it and/or

+ # modify it under the terms of the GNU General Public License version

+ # 2 as published by the Free Software Foundation.

  #

- # This program is distributed in the hope that it will be useful,

- # but WITHOUT ANY WARRANTY; without even the implied warranty of

- # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the

- # GNU General Public License for more details.

+ # This program is distributed in the hope that it will be useful, but

+ # WITHOUT ANY WARRANTY; without even the implied warranty of

+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU

+ # General Public License for more details.

  #

  # You should have received a copy of the GNU General Public License

- # along with this program; if not, If not, see

- # http://www.gnu.org/licenses/.

- #

- # About

- # =====

- # This script is used to generate mail to commits-list@gnome.org when change

- # are pushed to the GNOME git repository. It accepts input in the form of

- # a Git post-receive hook, and generates appropriate emails.

- #

- # The attempt here is to provide a maximimally useful and robust output

- # with as little clutter as possible.

- #

+ # along with this program.  If not, see

+ # <http://www.gnu.org/licenses/>.

  

- import re

- import os

- import pwd

- import sys

- from email.header import Header

- from socket import gethostname

+ # Sources: https://github.com/git-multimail/git-multimail/

  

- from kitchen.text.converters import to_bytes, to_unicode

- from kitchen.text.misc import byte_string_valid_encoding

+ """Generate notification emails for pushes to a git repository.

  

- script_path = os.path.realpath(os.path.abspath(sys.argv[0]))

- script_dir = os.path.dirname(script_path)

+ This hook sends emails describing changes introduced by pushes to a

+ git repository.  For each reference that was changed, it emits one

+ ReferenceChange email summarizing how the reference was changed,

+ followed by one Revision email for each new commit that was introduced

+ by the reference change.

  

- sys.path.insert(0, script_dir)

+ Each commit is announced in exactly one Revision email.  If the same

+ commit is merged into another branch in the same or a later push, then

+ the ReferenceChange email will list the commit's SHA1 and its one-line

+ summary, but no new Revision email will be generated.

  

- from git import *

- from util import die, strip_string as s, start_email, end_email

+ This script is designed to be used as a "post-receive" hook in a git

+ repository (see githooks(5)).  It can also be used as an "update"

+ script, but this usage is not completely reliable and is deprecated.

  

- # When we put a git subject into the Subject: line, where to truncate

- SUBJECT_MAX_SUBJECT_CHARS = 100

+ To help with debugging, this script accepts a --stdout option, which

+ causes the emails to be written to standard output rather than sent

+ using sendmail.

  

- CREATE = 0

- UPDATE = 1

- DELETE = 2

- INVALID_TAG = 3

+ See the accompanying README file for the complete documentation.

  

- # Short name for project

- projectshort = None

+ """

  

- # Project description

- projectdesc = None

+ import sys

+ import os

+ import re

+ import bisect

+ import socket

+ import subprocess

+ import shlex

+ import optparse

+ import logging

+ import smtplib

  

- # Human readable name for user, might be None

- user_fullname = None

+ try:

+     import ssl

+ except ImportError:

+     # Python < 2.6 do not have ssl, but that's OK if we don't use it.

+     pass

+ import time

+ import cgi

  

- # Who gets the emails

- recipients = None

+ PYTHON3 = sys.version_info >= (3, 0)

  

- # What domain the emails are from

- maildomain = None

+ if sys.version_info <= (2, 5):

  

- # short diff output only

- mailshortdiff = False

+     def all(iterable):

+         for element in iterable:

+             if not element:

+                 return False

+             return True

  

- # map of ref_name => Change object; this is used when computing whether

- # we've previously generated a detailed diff for a commit in the push

- all_changes = {}

- processed_changes = {}

  

- class RefChange(object):

-     def __init__(self, refname, oldrev, newrev):

-         self.refname = refname

-         self.oldrev = oldrev

-         self.newrev = newrev

+ def is_ascii(s):

+     return all(ord(c) < 128 and ord(c) > 0 for c in s)

  

-         if oldrev == None and newrev != None:

-             self.change_type = CREATE

-         elif oldrev != None and newrev == None:

-             self.change_type = DELETE

-         elif oldrev != None and newrev != None:

-             self.change_type = UPDATE

-         else:

-             self.change_type = INVALID_TAG

  

-         m = re.match(r"refs/[^/]*/(.*)", refname)

-         if m:

-             self.short_refname = m.group(1)

-         else:

-             self.short_refname = refname

+ if PYTHON3:

  

-     # Do any setup before sending email. The __init__ function should generally

-     # just record the parameters passed in and not do git work. (The main reason

-     # for the split is to let the prepare stage do different things based on

-     # whether other ref updates have been processed or not.)

-     def prepare(self):

-         pass

+     def is_string(s):

+         return isinstance(s, str)

  

-     # Whether we should generate the normal 'main' email. For simple branch

-     # updates we only generate 'extra' emails

-     def get_needs_main_email(self):

-         return True

+     def str_to_bytes(s):

+         return s.encode(ENCODING)

  

-     # The XXX in [projectname/XXX], usually a branch

-     def get_project_extra(self):

-         return None

+     def bytes_to_str(s, errors="strict"):

+         return s.decode(ENCODING, errors)

  

-     # Return the subject for the main email, without the leading [projectname]

-     def get_subject(self):

-         raise NotImplementedError()

+     unicode = str

  

-     # Write the body of the main email to the given file object

-     def generate_body(self, out):

-         raise NotImplementedError()

+     def write_str(f, msg):

+         # Try outputing with the default encoding. If it fails,

+         # try UTF-8.

+         try:

+             f.buffer.write(msg.encode(sys.getdefaultencoding()))

+         except UnicodeEncodeError:

+             f.buffer.write(msg.encode(ENCODING))

+ 

+     def read_line(f):

+         # Try reading with the default encoding. If it fails,

+         # try UTF-8.

+         out = f.buffer.readline()

+         try:

+             return out.decode(sys.getdefaultencoding())

+         except UnicodeEncodeError:

+             return out.decode(ENCODING)

  

-     def generate_header(self, out, subject, include_revs=True, oldrev=None, newrev=None):

-         user = os.environ['USER']

-         if user_fullname:

-             from_address = "%s <%s@%s>" % (user_fullname, user, maildomain)

-         else:

-             from_address = "%s@%s" % (user, maildomain)

  

-         if not byte_string_valid_encoding(to_bytes(subject), 'ascii'):

-             # non-ascii chars

-             subject = Header(to_bytes(to_unicode(subject)), 'utf-8').encode()

+ else:

  

-         print >>out, s("""

+     def is_string(s):

+         try:

+             return isinstance(s, basestring)

+         except NameError:  # Silence Pyflakes warning

+             raise

+ 

+     def str_to_bytes(s):

+         return s

+ 

+     def bytes_to_str(s, errors="strict"):

+         return s

+ 

+     def write_str(f, msg):

+         f.write(msg)

+ 

+     def read_line(f):

+         return f.readline()

+ 

+     def next(it):

+         return it.next()

+ 

+ 

+ try:

+     from email.charset import Charset

+     from email.utils import make_msgid

+     from email.utils import getaddresses

+     from email.utils import formataddr

+     from email.utils import formatdate

+     from email.header import Header

+ except ImportError:

+     # Prior to Python 2.5, the email module used different names:

+     from email.Charset import Charset

+     from email.Utils import make_msgid

+     from email.Utils import getaddresses

+     from email.Utils import formataddr

+     from email.Utils import formatdate

+     from email.Header import Header

+ 

+ 

+ DEBUG = False

+ 

+ ZEROS = "0" * 40

+ LOGBEGIN = (

+     "- Log -----------------------------------------------------------------\n"

+ )

+ LOGEND = (

+     "-----------------------------------------------------------------------\n"

+ )

+ 

+ ADDR_HEADERS = set(["from", "to", "cc", "bcc", "reply-to", "sender"])

+ 

+ # It is assumed in many places that the encoding is uniformly UTF-8,

+ # so changing these constants is unsupported.  But define them here

+ # anyway, to make it easier to find (at least most of) the places

+ # where the encoding is important.

+ (ENCODING, CHARSET) = ("UTF-8", "utf-8")

+ 

+ 

+ REF_CREATED_SUBJECT_TEMPLATE = (

+     "%(emailprefix)s%(refname_type)s %(short_refname)s created"

+     " (now %(newrev_short)s)"

+ )

+ REF_UPDATED_SUBJECT_TEMPLATE = (

+     "%(emailprefix)s%(refname_type)s %(short_refname)s updated"

+     " (%(oldrev_short)s -> %(newrev_short)s)"

+ )

+ REF_DELETED_SUBJECT_TEMPLATE = (

+     "%(emailprefix)s%(refname_type)s %(short_refname)s deleted"

+     " (was %(oldrev_short)s)"

+ )

+ 

+ COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE = (

+     "%(emailprefix)s%(refname_type)s %(short_refname)s updated: %(oneline)s"

+ )

+ 

+ REFCHANGE_HEADER_TEMPLATE = """\

+ Date: %(send_date)s

  To: %(recipients)s

- From: %(from_address)s

  Subject: %(subject)s

  MIME-Version: 1.0

+ Content-Type: text/%(contenttype)s; charset=%(charset)s

  Content-Transfer-Encoding: 8bit

- Content-Type: text/plain; charset="utf-8"

- Keywords: %(projectshort)s

- X-Project: %(projectdesc)s

+ Message-ID: %(msgid)s

+ From: %(fromaddr)s

+ Reply-To: %(reply_to)s

+ X-Git-Host: %(fqdn)s

+ X-Git-Repo: %(repo_shortname)s

  X-Git-Refname: %(refname)s

- """) % {

-             'recipients': to_bytes(recipients, errors='strict'),

-             'from_address': to_bytes(from_address, errors='strict'),

-             'subject': subject,

-             'projectshort': to_bytes(projectshort),

-             'projectdesc': to_bytes(projectdesc),

-             'refname': to_bytes(self.refname)

-        }

- 

-         if include_revs:

-             if oldrev:

-                 oldrev = oldrev

-             else:

-                 oldrev = NULL_REVISION

-             if newrev:

-                 newrev = newrev

-             else:

-                 newrev = NULL_REVISION

+ X-Git-Reftype: %(refname_type)s

+ X-Git-Oldrev: %(oldrev)s

+ X-Git-Newrev: %(newrev)s

+ X-Git-NotificationType: ref_changed

+ X-Git-Multimail-Version: %(multimail_version)s

+ Auto-Submitted: auto-generated

+ """

+ 

+ REFCHANGE_INTRO_TEMPLATE = """\

+ This is an automated email from the git hooks/post-receive script.

+ 

+ %(pusher)s pushed a change to %(refname_type)s %(short_refname)s

+ in repository %(repo_shortname)s.

+ 

+ """

+ 

+ 

+ FOOTER_TEMPLATE = """\

+ 

+ -- \n\

+ To stop receiving notification emails like this one, please contact

+ %(administrator)s.

+ """

+ 

+ 

+ REWIND_ONLY_TEMPLATE = """\

+ This update removed existing revisions from the reference, leaving the

+ reference pointing at a previous point in the repository history.

+ 

+  * -- * -- N   %(refname)s (%(newrev_short)s)

+             \\

+              O -- O -- O   (%(oldrev_short)s)

+ 

+ Any revisions marked "omit" are not gone; other references still

+ refer to them.  Any revisions marked "discard" are gone forever.

+ """

+ 

+ 

+ NON_FF_TEMPLATE = """\

+ This update added new revisions after undoing existing revisions.

+ That is to say, some revisions that were in the old version of the

+ %(refname_type)s are not in the new version.  This situation occurs

+ when a user --force pushes a change and generates a repository

+ containing something like this:

+ 

+  * -- * -- B -- O -- O -- O   (%(oldrev_short)s)

+             \\

+              N -- N -- N   %(refname)s (%(newrev_short)s)

+ 

+ You should already have received notification emails for all of the O

+ revisions, and so the following emails describe only the N revisions

+ from the common base, B.

+ 

+ Any revisions marked "omit" are not gone; other references still

+ refer to them.  Any revisions marked "discard" are gone forever.

+ """

+ 

+ 

+ NO_NEW_REVISIONS_TEMPLATE = """\

+ No new revisions were added by this update.

+ """

+ 

+ 

+ DISCARDED_REVISIONS_TEMPLATE = """\

+ This change permanently discards the following revisions:

+ """

+ 

+ 

+ NO_DISCARDED_REVISIONS_TEMPLATE = """\

+ The revisions that were on this %(refname_type)s are still contained in

+ other references; therefore, this change does not discard any commits

+ from the repository.

+ """

+ 

+ 

+ NEW_REVISIONS_TEMPLATE = """\

+ The %(tot)s revisions listed above as "new" are entirely new to this

+ repository and will be described in separate emails.  The revisions

+ listed as "add" were already present in the repository and have only

+ been added to this reference.

+ 

+ """

+ 

+ 

+ TAG_CREATED_TEMPLATE = """\

+       at %(newrev_short)-8s (%(newrev_type)s)

+ """

+ 

  

-             print >>out, s("""

+ TAG_UPDATED_TEMPLATE = """\

+ *** WARNING: tag %(short_refname)s was modified! ***

+ 

+     from %(oldrev_short)-8s (%(oldrev_type)s)

+       to %(newrev_short)-8s (%(newrev_type)s)

+ """

+ 

+ 

+ TAG_DELETED_TEMPLATE = """\

+ *** WARNING: tag %(short_refname)s was deleted! ***

+ 

+ """

+ 

+ 

+ # The template used in summary tables.  It looks best if this uses the

+ # same alignment as TAG_CREATED_TEMPLATE and TAG_UPDATED_TEMPLATE.

+ BRIEF_SUMMARY_TEMPLATE = """\

+ %(action)8s %(rev_short)-8s %(text)s

+ """

+ 

+ 

+ NON_COMMIT_UPDATE_TEMPLATE = """\

+ This is an unusual reference change because the reference did not

+ refer to a commit either before or after the change.  We do not know

+ how to provide full information about this reference change.

+ """

+ 

+ 

+ REVISION_HEADER_TEMPLATE = """\

+ Date: %(send_date)s

+ To: %(recipients)s

+ Cc: %(cc_recipients)s

+ Subject: %(emailprefix)s%(num)02d/%(tot)02d: %(oneline)s

+ MIME-Version: 1.0

+ Content-Type: text/%(contenttype)s; charset=%(charset)s

+ Content-Transfer-Encoding: 8bit

+ From: %(fromaddr)s

+ Reply-To: %(reply_to)s

+ In-Reply-To: %(reply_to_msgid)s

+ References: %(reply_to_msgid)s

+ X-Git-Host: %(fqdn)s

+ X-Git-Repo: %(repo_shortname)s

+ X-Git-Refname: %(refname)s

+ X-Git-Reftype: %(refname_type)s

+ X-Git-Rev: %(rev)s

+ X-Git-NotificationType: diff

+ X-Git-Multimail-Version: %(multimail_version)s

+ Auto-Submitted: auto-generated

+ """

+ 

+ REVISION_INTRO_TEMPLATE = """\

+ This is an automated email from the git hooks/post-receive script.

+ 

+ %(pusher)s pushed a commit to %(refname_type)s %(short_refname)s

+ in repository %(repo_shortname)s.

+ 

+ """

+ 

+ LINK_TEXT_TEMPLATE = """\

+ View the commit online:

+ %(browse_url)s

+ 

+ """

+ 

+ LINK_HTML_TEMPLATE = """\

+ <p><a href="%(browse_url)s">View the commit online</a>.</p>

+ """

+ 

+ 

+ REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE

+ 

+ 

+ # Combined, meaning refchange+revision email (for single-commit additions)

+ COMBINED_HEADER_TEMPLATE = """\

+ Date: %(send_date)s

+ To: %(recipients)s

+ Subject: %(subject)s

+ MIME-Version: 1.0

+ Content-Type: text/%(contenttype)s; charset=%(charset)s

+ Content-Transfer-Encoding: 8bit

+ Message-ID: %(msgid)s

+ From: %(fromaddr)s

+ Reply-To: %(reply_to)s

+ X-Git-Host: %(fqdn)s

+ X-Git-Repo: %(repo_shortname)s

+ X-Git-Refname: %(refname)s

+ X-Git-Reftype: %(refname_type)s

  X-Git-Oldrev: %(oldrev)s

  X-Git-Newrev: %(newrev)s

- """) % {

-             'oldrev': to_bytes(oldrev),

-             'newrev': to_bytes(newrev),

-        }

+ X-Git-Rev: %(rev)s

+ X-Git-NotificationType: ref_changed_plus_diff

+ X-Git-Multimail-Version: %(multimail_version)s

+ Auto-Submitted: auto-generated

+ """

  

-         # Trailing newline to signal the end of the header

-         print >>out

+ COMBINED_INTRO_TEMPLATE = """\

+ This is an automated email from the git hooks/post-receive script.

+ 

+ %(pusher)s pushed a commit to %(refname_type)s %(short_refname)s

+ in repository %(repo_shortname)s.

+ 

+ """

+ 

+ COMBINED_FOOTER_TEMPLATE = FOOTER_TEMPLATE

+ 

+ 

+ class CommandError(Exception):

+     def __init__(self, cmd, retcode):

+         self.cmd = cmd

+         self.retcode = retcode

+         Exception.__init__(

+             self,

+             'Command "%s" failed with retcode %s' % (" ".join(cmd), retcode),

+         )

+ 

+ 

+ class ConfigurationException(Exception):

+     pass

+ 

+ 

+ # The "git" program (this could be changed to include a full path):

+ GIT_EXECUTABLE = "git"

+ 

+ 

+ # How "git" should be invoked (including global arguments), as a list

+ # of words.  This variable is usually initialized automatically by

+ # read_git_output() via choose_git_command(), but if a value is set

+ # here then it will be used unconditionally.

+ GIT_CMD = None

+ 

+ 

+ def choose_git_command():

+     """Decide how to invoke git, and record the choice in GIT_CMD."""

+ 

+     global GIT_CMD

+ 

+     if GIT_CMD is None:

+         try:

+             # Check to see whether the "-c" option is accepted (it was

+             # only added in Git 1.7.2).  We don't actually use the

+             # output of "git --version", though if we needed more

+             # specific version information this would be the place to

+             # do it.

+             cmd = [GIT_EXECUTABLE, "-c", "foo.bar=baz", "--version"]

+             read_output(cmd)

+             GIT_CMD = [

+                 GIT_EXECUTABLE,

+                 "-c",

+                 "i18n.logoutputencoding=%s" % (ENCODING,),

+             ]

+         except CommandError:

+             GIT_CMD = [GIT_EXECUTABLE]

+ 

+ 

+ def read_git_output(args, input=None, keepends=False, **kw):

+     """Read the output of a Git command."""

+ 

+     if GIT_CMD is None:

+         choose_git_command()

+ 

+     return read_output(GIT_CMD + args, input=input, keepends=keepends, **kw)

+ 

+ 

+ def read_output(cmd, input=None, keepends=False, **kw):

+     if input:

+         stdin = subprocess.PIPE

+         input = str_to_bytes(input)

+     else:

+         stdin = None

+     errors = "strict"

+     if "errors" in kw:

+         errors = kw["errors"]

+         del kw["errors"]

+     p = subprocess.Popen(

+         tuple(str_to_bytes(w) for w in cmd),

+         stdin=stdin,

+         stdout=subprocess.PIPE,

+         stderr=subprocess.PIPE,

+         **kw

+     )

+     (out, err) = p.communicate(input)

+     out = bytes_to_str(out, errors=errors)

+     retcode = p.wait()

+     if retcode:

+         raise CommandError(cmd, retcode)

+     if not keepends:

+         out = out.rstrip("\n\r")

+     return out

+ 

+ 

+ def read_git_lines(args, keepends=False, **kw):

+     """Return the lines output by Git command.

+ 

+     Return as single lines, with newlines stripped off."""

+ 

+     return read_git_output(args, keepends=True, **kw).splitlines(keepends)

+ 

+ 

+ def git_rev_list_ish(cmd, spec, args=None, **kw):

+     """Common functionality for invoking a 'git rev-list'-like command.

+ 

+     Parameters:

+       * cmd is the Git command to run, e.g., 'rev-list' or 'log'.

+       * spec is a list of revision arguments to pass to the named

+         command.  If None, this function returns an empty list.

+       * args is a list of extra arguments passed to the named command.

+       * All other keyword arguments (if any) are passed to the

+         underlying read_git_lines() function.

+ 

+     Return the output of the Git command in the form of a list, one

+     entry per output line.

+     """

+     if spec is None:

+         return []

+     if args is None:

+         args = []

+     args = [cmd, "--stdin"] + args

+     spec_stdin = "".join(s + "\n" for s in spec)

+     return read_git_lines(args, input=spec_stdin, **kw)

+ 

+ 

+ def git_rev_list(spec, **kw):

+     """Run 'git rev-list' with the given list of revision arguments.

+ 

+     See git_rev_list_ish() for parameter and return value

+     documentation.

+     """

+     return git_rev_list_ish("rev-list", spec, **kw)

+ 

+ 

+ def git_log(spec, **kw):

+     """Run 'git log' with the given list of revision arguments.

+ 

+     See git_rev_list_ish() for parameter and return value

+     documentation.

+     """

+     return git_rev_list_ish("log", spec, **kw)

+ 

+ 

+ def header_encode(text, header_name=None):

+     """Encode and line-wrap the value of an email header field."""

+ 

+     # Convert to unicode, if required.

+     if not isinstance(text, unicode):

+         text = unicode(text, "utf-8")

+ 

+     if is_ascii(text):

+         charset = "ascii"

+     else:

+         charset = "utf-8"

+ 

+     return Header(

+         text, header_name=header_name, charset=Charset(charset)

+     ).encode()

+ 

+ 

+ def addr_header_encode(text, header_name=None):

+     """Encode and line-wrap the value of an email header field containing

+     email addresses."""

+ 

+     # Convert to unicode, if required.

+     if not isinstance(text, unicode):

+         text = unicode(text, "utf-8")

+ 

+     text = ", ".join(

+         formataddr((header_encode(name), emailaddr))

+         for name, emailaddr in getaddresses([text])

+     )

+ 

+     if is_ascii(text):

+         charset = "ascii"

+     else:

+         charset = "utf-8"

+ 

+     return Header(

+         text, header_name=header_name, charset=Charset(charset)

+     ).encode()

  

-     def send_main_email(self):

-         if not self.get_needs_main_email():

-             return

  

-         extra = self.get_project_extra()

-         if extra:

-             extra = "/" + extra

+ class Config(object):

+     def __init__(self, section, git_config=None):

+         """Represent a section of the git configuration.

+ 

+         If git_config is specified, it is passed to "git config" in

+         the GIT_CONFIG environment variable, meaning that "git config"

+         will read the specified path rather than the Git default

+         config paths."""

+ 

+         self.section = section

+         if git_config:

+             self.env = os.environ.copy()

+             self.env["GIT_CONFIG"] = git_config

          else:

-             extra = ""

-         subject = "[" + projectshort + extra + "] " + self.get_subject()

+             self.env = None

+ 

+     @staticmethod

+     def _split(s):

+         """Split NUL-terminated values."""

+ 

+         words = s.split("\0")

+         assert words[-1] == ""

+         return words[:-1]

+ 

+     @staticmethod

+     def add_config_parameters(c):

+         """Add configuration parameters to Git.

+ 

+         c is either an str or a list of str, each element being of the

+         form 'var=val' or 'var', with the same syntax and meaning as

+         the argument of 'git -c var=val'.

+         """

+         if isinstance(c, str):

+             c = (c,)

+         parameters = os.environ.get("GIT_CONFIG_PARAMETERS", "")

+         if parameters:

+             parameters += " "

+         # git expects GIT_CONFIG_PARAMETERS to be of the form

+         #    "'name1=value1' 'name2=value2' 'name3=value3'"

+         # including everything inside the double quotes (but not the double

+         # quotes themselves).  Spacing is critical.  Also, if a value contains

+         # a literal single quote that quote must be represented using the

+         # four character sequence: '\''

+         parameters += " ".join("'" + x.replace("'", "'\\''") + "'" for x in c)

+         os.environ["GIT_CONFIG_PARAMETERS"] = parameters

+ 

+     def get(self, name, default=None):

+         try:

+             values = self._split(

+                 read_git_output(

+                     [

+                         "config",

+                         "--get",

+                         "--null",

+                         "%s.%s" % (self.section, name),

+                     ],

+                     env=self.env,

+                     keepends=True,

+                 )

+             )

+             assert len(values) == 1

+             return values[0]

+         except CommandError:

+             return default

+ 

+     def get_bool(self, name, default=None):

+         try:

+             value = read_git_output(

+                 ["config", "--get", "--bool", "%s.%s" % (self.section, name)],

+                 env=self.env,

+             )

+         except CommandError:

+             return default

+         return value == "true"

  

-         email_out = start_email()

+     def get_all(self, name, default=None):

+         """Read a (possibly multivalued) setting from the configuration.

  

-         self.generate_header(email_out, subject, include_revs=True, oldrev=self.oldrev, newrev=self.newrev)

-         self.generate_body(email_out)

+         Return the result as a list of values, or default if the name

+         is unset."""

  

-         end_email()

+         try:

+             return self._split(

+                 read_git_output(

+                     [

+                         "config",

+                         "--get-all",

+                         "--null",

+                         "%s.%s" % (self.section, name),

+                     ],

+                     env=self.env,

+                     keepends=True,

+                 )

+             )

+         except CommandError:

+             t, e, traceback = sys.exc_info()

+             if e.retcode == 1:

+                 # "the section or key is invalid"; i.e., there is no

+                 # value for the specified key.

+                 return default

+             else:

+                 raise

  

-     # Allow multiple emails to be sent - used for branch updates

-     def send_extra_emails(self):

-         pass

+     def set(self, name, value):

+         read_git_output(

+             ["config", "%s.%s" % (self.section, name), value], env=self.env

+         )

  

-     def send_emails(self):

-         self.send_main_email()

-         self.send_extra_emails()

+     def add(self, name, value):

+         read_git_output(

+             ["config", "--add", "%s.%s" % (self.section, name), value],

+             env=self.env,

+         )

  

- # ========================

+     def __contains__(self, name):

+         return self.get_all(name, default=None) is not None

  

- # Common baseclass for BranchCreation and BranchUpdate (but not BranchDeletion)

- class BranchChange(RefChange):

-     def __init__(self, *args):

-         RefChange.__init__(self, *args)

+     # We don't use this method anymore internally, but keep it here in

+     # case somebody is calling it from their own code:

+     def has_key(self, name):

+         return name in self

  

-     def prepare(self):

-         # We need to figure out what commits are referenced in this commit thta

-         # weren't previously referenced in the repository by another branch.

-         # "Previously" here means either before this push, or by branch updates

-         # we've already done in this push. These are the commits we'll send

-         # out individual mails for.

-         #

-         # Note that "Before this push" can't be gotten exactly right since an

-         # push is only atomic per-branch and there is no locking across branches.

-         # But new commits will always show up in a cover mail in any case; even

-         # someone who maliciously is trying to fool us can't hide all trace.

- 

-         # Ordering matters here, so we can't rely on kwargs

-         branches = git.rev_parse('--symbolic-full-name', '--branches', _split_lines=True)

-         detailed_commit_args = [ self.newrev ]

- 

-         for branch in branches:

-             if branch == self.refname:

-                 # For this branch, exclude commits before 'oldrev'

-                 if self.change_type != CREATE:

-                     detailed_commit_args.append("^" + self.oldrev)

-             elif branch in all_changes and not branch in processed_changes:

-                 # For branches that were updated in this push but we haven't processed

-                 # yet, exclude commits before their old revisions

-                 detailed_commit_args.append("^" + all_changes[branch].oldrev)

+     def unset_all(self, name):

+         try:

+             read_git_output(

+                 ["config", "--unset-all", "%s.%s" % (self.section, name)],

+                 env=self.env,

+             )

+         except CommandError:

+             t, e, traceback = sys.exc_info()

+             if e.retcode == 5:

+                 # The name doesn't exist, which is what we wanted anyway...

+                 pass

              else:

-                 # Exclude commits that are ancestors of all other branches

-                 detailed_commit_args.append("^" + branch)

- 

-         detailed_commits = git.rev_list(*detailed_commit_args).splitlines()

- 

-         self.detailed_commits = set()

-         for id in detailed_commits:

-             self.detailed_commits.add(id)

- 

-         # Find the commits that were added and removed, reverse() to get

-         # chronological order

-         if self.change_type == CREATE:

-             # If someone creates a branch of GTK+, we don't want to list (or even walk through)

-             # all 30,000 commits in the history as "new commits" on the branch. So we start

-             # the commit listing from the first commit we are going to send a mail out about.

-             #

-             # This does mean that if someone creates a branch, merges it, and then pushes

-             # both the branch and what was merged into at once, then the resulting mails will

-             # be a bit strange (depending on ordering) - the mail for the creation of the

-             # branch may look like it was created in the finished state because all the commits

-             # have been already mailed out for the other branch. I don't think this is a big

-             # problem, and the best way to fix it would be to sort the ref updates so that the

-             # branch creation was processed first.

-             #

-             if len(detailed_commits) > 0:

-                 # Verify parent of first detailed commit is valid. On initial push, it is not.

-                 parent = detailed_commits[-1] + "^"

+                 raise

+ 

+     def set_recipients(self, name, value):

+         self.unset_all(name)

+         for pair in getaddresses([value]):

+             self.add(name, formataddr(pair))

+ 

+ 

+ def generate_summaries(*log_args):

+     """Generate a brief summary for each revision requested.

+ 

+     log_args are strings that will be passed directly to "git log" as

+     revision selectors.  Iterate over (sha1_short, subject) for each

+     commit specified by log_args (subject is the first line of the

+     commit message as a string without EOLs)."""

+ 

+     cmd = ["log", "--abbrev", "--format=%h %s"] + list(log_args) + ["--"]

+     for line in read_git_lines(cmd):

+         yield tuple(line.split(" ", 1))

+ 

+ 

+ def limit_lines(lines, max_lines):

+     for (index, line) in enumerate(lines):

+         if index < max_lines:

+             yield line

+ 

+     if index >= max_lines:

+         yield "... %d lines suppressed ...\n" % (index + 1 - max_lines,)

+ 

+ 

+ def limit_linelength(lines, max_linelength):

+     for line in lines:

+         # Don't forget that lines always include a trailing newline.

+         if len(line) > max_linelength + 1:

+             line = line[: max_linelength - 7] + " [...]\n"

+         yield line

+ 

+ 

+ class CommitSet(object):

+     """A (constant) set of object names.

+ 

+     The set should be initialized with full SHA1 object names.  The

+     __contains__() method returns True iff its argument is an

+     abbreviation of any the names in the set."""

+ 

+     def __init__(self, names):

+         self._names = sorted(names)

+ 

+     def __len__(self):

+         return len(self._names)

+ 

+     def __contains__(self, sha1_abbrev):

+         """Return True iff this set contains sha1_abbrev (which might be abbreviated)."""

+ 

+         i = bisect.bisect_left(self._names, sha1_abbrev)

+         return i < len(self) and self._names[i].startswith(sha1_abbrev)

+ 

+ 

+ class GitObject(object):

+     def __init__(self, sha1, type=None):

+         if sha1 == ZEROS:

+             self.sha1 = self.type = self.commit_sha1 = None

+         else:

+             self.sha1 = sha1

+             self.type = type or read_git_output(["cat-file", "-t", self.sha1])

+ 

+             if self.type == "commit":

+                 self.commit_sha1 = self.sha1

+             elif self.type == "tag":

                  try:

-                     validref = git.rev_parse(parent, _quiet=True)

-                 except CalledProcessError, error:

-                     self.added_commits = []

-                 else:

-                     self.added_commits = rev_list_commits(parent + ".." + self.newrev)

-                     self.added_commits.reverse()

+                     self.commit_sha1 = read_git_output(

+                         ["rev-parse", "--verify", "%s^0" % (self.sha1,)]

+                     )

+                 except CommandError:

+                     # Cannot deref tag to determine commit_sha1

+                     self.commit_sha1 = None

              else:

-                 self.added_commits = []

-             self.removed_commits = []

+                 self.commit_sha1 = None

+ 

+         self.short = read_git_output(["rev-parse", "--short", sha1])

+ 

+     def get_summary(self):

+         """Return (sha1_short, subject) for this commit."""

+ 

+         if not self.sha1:

+             raise ValueError("Empty commit has no summary")

+ 

+         return next(iter(generate_summaries("--no-walk", self.sha1)))

+ 

+     def __eq__(self, other):

+         return isinstance(other, GitObject) and self.sha1 == other.sha1

+ 

+     def __hash__(self):

+         return hash(self.sha1)

+ 

+     def __nonzero__(self):

+         return bool(self.sha1)

+ 

+     def __bool__(self):

+         """Python 2 backward compatibility"""

+         return self.__nonzero__()

+ 

+     def __str__(self):

+         return self.sha1 or ZEROS

+ 

+ 

+ class Change(object):

+     """A Change that has been made to the Git repository.

+ 

+     Abstract class from which both Revisions and ReferenceChanges are

+     derived.  A Change knows how to generate a notification email

+     describing itself."""

+ 

+     def __init__(self, environment):

+         self.environment = environment

+         self._values = None

+         self._contains_html_diff = False

+ 

+     def _contains_diff(self):

+         # We do contain a diff, should it be rendered in HTML?

+         if self.environment.commit_email_format == "html":

+             self._contains_html_diff = True

+ 

+     def _compute_values(self):

+         """Return a dictionary {keyword: expansion} for this Change.

+ 

+         Derived classes overload this method to add more entries to

+         the return value.  This method is used internally by

+         get_values().  The return value should always be a new

+         dictionary."""

+ 

+         values = self.environment.get_values()

+         fromaddr = self.environment.get_fromaddr(change=self)

+         if fromaddr is not None:

+             values["fromaddr"] = fromaddr

+         values["multimail_version"] = get_version()

+         return values

+ 

+     # Aliases usable in template strings. Tuple of pairs (destination,

+     # source).

+     VALUES_ALIAS = (("id", "newrev"),)

+ 

+     def get_values(self, **extra_values):

+         """Return a dictionary {keyword: expansion} for this Change.

+ 

+         Return a dictionary mapping keywords to the values that they

+         should be expanded to for this Change (used when interpolating

+         template strings).  If any keyword arguments are supplied, add

+         those to the return value as well.  The return value is always

+         a new dictionary."""

+ 

+         if self._values is None:

+             self._values = self._compute_values()

+ 

+         values = self._values.copy()

+         if extra_values:

+             values.update(extra_values)

+ 

+         for alias, val in self.VALUES_ALIAS:

+             values[alias] = values[val]

+         return values

+ 

+     def expand(self, template, **extra_values):

+         """Expand template.

+ 

+         Expand the template (which should be a string) using string

+         interpolation of the values for this Change.  If any keyword

+         arguments are provided, also include those in the keywords

+         available for interpolation."""

+ 

+         return template % self.get_values(**extra_values)

+ 

+     def expand_lines(self, template, html_escape_val=False, **extra_values):

+         """Break template into lines and expand each line."""

+ 

+         values = self.get_values(**extra_values)

+         if html_escape_val:

+             for k in values:

+                 if is_string(values[k]):

+                     values[k] = cgi.escape(values[k], True)

+         for line in template.splitlines(True):

+             yield line % values

+ 

+     def expand_header_lines(self, template, **extra_values):

+         """Break template into lines and expand each line as an RFC 2822 header.

+ 

+         Encode values and split up lines that are too long.  Silently

+         skip lines that contain references to unknown variables."""

+ 

+         values = self.get_values(**extra_values)

+         if self._contains_html_diff:

+             self._content_type = "html"

          else:

-             self.added_commits = rev_list_commits(self.oldrev + ".." + self.newrev)

-             self.added_commits.reverse()

-             self.removed_commits = rev_list_commits(self.newrev + ".." + self.oldrev)

-             self.removed_commits.reverse()

+             self._content_type = "plain"

+         values["contenttype"] = self._content_type

+ 

+         for line in template.splitlines():

+             (name, value) = line.split(": ", 1)

+ 

+             try:

+                 value = value % values

+             except KeyError:

+                 t, e, traceback = sys.exc_info()

+                 if DEBUG:

+                     self.environment.log_warning(

+                         "Warning: unknown variable %r in the following line; line skipped:\n"

+                         "    %s\n" % (e.args[0], line)

+                     )

+             else:

+                 if name.lower() in ADDR_HEADERS:

+                     value = addr_header_encode(value, name)

+                 else:

+                     value = header_encode(value, name)

+                 for splitline in ("%s: %s\n" % (name, value)).splitlines(True):

+                     yield splitline

  

-         # In some cases we'll send a cover email that describes the overall

-         # change to the branch before ending individual mails for commits. In other

-         # cases, we just send the individual emails. We generate a cover mail:

-         #

-         # - If it's a branch creation

-         # - If it's not a fast forward

-         # - If there are any merge commits

-         # - If there are any commits we won't send separately (already in repo)

+     def generate_email_header(self):

+         """Generate the RFC 2822 email headers for this Change, a line at a time.

+ 

+         The output should not include the trailing blank line."""

+ 

+         raise NotImplementedError()

+ 

+     def generate_browse_link(self, base_url):

+         """Generate a link to an online repository browser."""

+         return iter(())

+ 

+     def generate_email_intro(self, html_escape_val=False):

+         """Generate the email intro for this Change, a line at a time.

  

-         have_merge_commits = False

-         for commit in self.added_commits:

-             if commit_is_merge(commit):

-                 have_merge_commits = True

+         The output will be used as the standard boilerplate at the top

+         of the email body."""

  

-         self.needs_cover_email = (self.change_type == CREATE or

-                                   len(self.removed_commits) > 0 or

-                                   have_merge_commits or

-                                   len(self.detailed_commits) < len(self.added_commits))

+         raise NotImplementedError()

+ 

+     def generate_email_body(self):

+         """Generate the main part of the email body, a line at a time.

+ 

+         The text in the body might be truncated after a specified

+         number of lines (see multimailhook.emailmaxlines)."""

+ 

+         raise NotImplementedError()

+ 

+     def generate_email_footer(self, html_escape_val):

+         """Generate the footer of the email, a line at a time.

+ 

+         The footer is always included, irrespective of

+         multimailhook.emailmaxlines."""

  

-     def get_needs_main_email(self):

-         return self.needs_cover_email

+         raise NotImplementedError()

+ 

+     def _wrap_for_html(self, lines):

+         """Wrap the lines in HTML <pre> tag when using HTML format.

+ 

+         Escape special HTML characters and add <pre> and </pre> tags around

+         the given lines if we should be generating HTML as indicated by

+         self._contains_html_diff being set to true.

+         """

+         if self._contains_html_diff:

+             yield "<pre style='margin:0'>\n"

+ 

+             for line in lines:

+                 yield cgi.escape(line)

  

-     # A prefix for the cover letter summary with the number of added commits

-     def get_count_string(self):

-         if len(self.added_commits) > 1:

-             return "(%d commits) " % len(self.added_commits)

+             yield "</pre>\n"

          else:

-             return ""

+             for line in lines:

+                 yield line

+ 

+     def generate_email(self, push, body_filter=None, extra_header_values={}):

+         """Generate an email describing this change.

+ 

+         Iterate over the lines (including the header lines) of an

+         email describing this change.  If body_filter is not None,

+         then use it to filter the lines that are intended for the

+         email body.

+ 

+         The extra_header_values field is received as a dict and not as

+         **kwargs, to allow passing other keyword arguments in the

+         future (e.g. passing extra values to generate_email_intro()"""

+ 

+         for line in self.generate_email_header(**extra_header_values):

+             yield line

+         yield "\n"

+         html_escape_val = (

+             self.environment.html_in_intro and self._contains_html_diff

+         )

+         intro = self.generate_email_intro(html_escape_val)

+         if not self.environment.html_in_intro:

+             intro = self._wrap_for_html(intro)

+         for line in intro:

+             yield line

+ 

+         if self.environment.commitBrowseURL:

+             for line in self.generate_browse_link(

+                 self.environment.commitBrowseURL

+             ):

+                 yield line

+ 

+         body = self.generate_email_body(push)

+         if body_filter is not None:

+             body = body_filter(body)

+ 

+         diff_started = False

+         if self._contains_html_diff:

+             # "white-space: pre" is the default, but we need to

+             # specify it again in case the message is viewed in a

+             # webmail which wraps it in an element setting white-space

+             # to something else (Zimbra does this and sets

+             # white-space: pre-line).

+             yield '<pre style="white-space: pre; background: #F8F8F8">'

+         for line in body:

+             if self._contains_html_diff:

+                 # This is very, very naive. It would be much better to really

+                 # parse the diff, i.e. look at how many lines do we have in

+                 # the hunk headers instead of blindly highlighting everything

+                 # that looks like it might be part of a diff.

+                 bgcolor = ""

+                 fgcolor = ""

+                 if line.startswith("--- a/"):

+                     diff_started = True

+                     bgcolor = "e0e0ff"

+                 elif line.startswith("diff ") or line.startswith("index "):

+                     diff_started = True

+                     fgcolor = "808080"

+                 elif diff_started:

+                     if line.startswith("+++ "):

+                         bgcolor = "e0e0ff"

+                     elif line.startswith("@@"):

+                         bgcolor = "e0e0e0"

+                     elif line.startswith("+"):

+                         bgcolor = "e0ffe0"

+                     elif line.startswith("-"):

+                         bgcolor = "ffe0e0"

+                 elif line.startswith("commit "):

+                     fgcolor = "808000"

+                 elif line.startswith("    "):

+                     fgcolor = "404040"

+ 

+                 # Chop the trailing LF, we don't want it inside <pre>.

+                 line = cgi.escape(line[:-1])

+ 

+                 if bgcolor or fgcolor:

+                     style = "display:block; white-space:pre;"

+                     if bgcolor:

+                         style += "background:#" + bgcolor + ";"

+                     if fgcolor:

+                         style += "color:#" + fgcolor + ";"

+                     # Use a <span style='display:block> to color the

+                     # whole line. The newline must be inside the span

+                     # to display properly both in Firefox and in

+                     # text-based browser.

+                     line = "<span style='%s'>%s\n</span>" % (style, line)

+                 else:

+                     line = line + "\n"

+ 

+             yield line

+         if self._contains_html_diff:

+             yield "</pre>"

+         html_escape_val = (

+             self.environment.html_in_footer and self._contains_html_diff

+         )

+         footer = self.generate_email_footer(html_escape_val)

+         if not self.environment.html_in_footer:

+             footer = self._wrap_for_html(footer)

+         for line in footer:

+             yield line

+ 

+     def get_specific_fromaddr(self):

+         """For kinds of Changes which specify it, return the kind-specific

+         From address to use."""

+         return None

  

-     # Generate a short listing for a series of commits

-     # show_details - whether we should mark commit where we aren't going to send

-     # a detailed email. (Set the False when listing removed commits)

-     def generate_commit_summary(self, out, commits, show_details=True):

-         detail_note = False

-         for commit in commits:

-             if show_details and not commit.id in self.detailed_commits:

-                 detail = " (*)"

-                 detail_note = True

+ 

+ class Revision(Change):

+     """A Change consisting of a single git commit."""

+ 

+     CC_RE = re.compile(r"^\s*C[Cc]:\s*(?P<to>[^#]+@[^\s#]*)\s*(#.*)?$")

+ 

+     def __init__(self, reference_change, rev, num, tot):

+         Change.__init__(self, reference_change.environment)

+         self.reference_change = reference_change

+         self.rev = rev

+         self.change_type = self.reference_change.change_type

+         self.refname = self.reference_change.refname

+         self.num = num

+         self.tot = tot

+         self.author = read_git_output(

+             ["log", "--no-walk", "--format=%aN <%aE>", self.rev.sha1]

+         )

+         self.recipients = self.environment.get_revision_recipients(self)

+ 

+         self.cc_recipients = ""

+         if self.environment.get_scancommitforcc():

+             self.cc_recipients = ", ".join(

+                 to.strip() for to in self._cc_recipients()

+             )

+             if self.cc_recipients:

+                 self.environment.log_msg(

+                     "Add %s to CC for %s" % (self.cc_recipients, self.rev.sha1)

+                 )

+ 

+     def _cc_recipients(self):

+         cc_recipients = []

+         message = read_git_output(

+             ["log", "--no-walk", "--format=%b", self.rev.sha1]

+         )

+         lines = message.strip().split("\n")

+         for line in lines:

+             m = re.match(self.CC_RE, line)

+             if m:

+                 cc_recipients.append(m.group("to"))

+ 

+         return cc_recipients

+ 

+     def _compute_values(self):

+         values = Change._compute_values(self)

+ 

+         oneline = read_git_output(

+             ["log", "--format=%s", "--no-walk", self.rev.sha1]

+         )

+ 

+         max_subject_length = self.environment.get_max_subject_length()

+         if max_subject_length > 0 and len(oneline) > max_subject_length:

+             oneline = oneline[: max_subject_length - 6] + " [...]"

+ 

+         values["rev"] = self.rev.sha1

+         values["rev_short"] = self.rev.short

+         values["change_type"] = self.change_type

+         values["refname"] = self.refname

+         values["newrev"] = self.rev.sha1

+         values["short_refname"] = self.reference_change.short_refname

+         values["refname_type"] = self.reference_change.refname_type

+         values["reply_to_msgid"] = self.reference_change.msgid

+         values["num"] = self.num

+         values["tot"] = self.tot

+         values["recipients"] = self.recipients

+         if self.cc_recipients:

+             values["cc_recipients"] = self.cc_recipients

+         values["oneline"] = oneline

+         values["author"] = self.author

+ 

+         reply_to = self.environment.get_reply_to_commit(self)

+         if reply_to:

+             values["reply_to"] = reply_to

+ 

+         return values

+ 

+     def generate_email_header(self, **extra_values):

+         for line in self.expand_header_lines(

+             REVISION_HEADER_TEMPLATE, **extra_values

+         ):

+             yield line

+ 

+     def generate_browse_link(self, base_url):

+         if "%(" not in base_url:

+             base_url += "%(id)s"

+         url = "".join(self.expand_lines(base_url))

+         if self._content_type == "html":

+             for line in self.expand_lines(

+                 LINK_HTML_TEMPLATE, html_escape_val=True, browse_url=url

+             ):

+                 yield line

+         elif self._content_type == "plain":

+             for line in self.expand_lines(

+                 LINK_TEXT_TEMPLATE, html_escape_val=False, browse_url=url

+             ):

+                 yield line

+         else:

+             raise NotImplementedError(

+                 "Content-type %s unsupported. Please report it as a bug."

+             )

+ 

+     def generate_email_intro(self, html_escape_val=False):

+         for line in self.expand_lines(

+             REVISION_INTRO_TEMPLATE, html_escape_val=html_escape_val

+         ):

+             yield line

+ 

+     def generate_email_body(self, push):

+         """Show this revision."""

+ 

+         for line in read_git_lines(

+             ["log"] + self.environment.commitlogopts + ["-1", self.rev.sha1],

+             keepends=True,

+             errors="replace",

+         ):

+             if (

+                 line.startswith("Date:   ")

+                 and self.environment.date_substitute

+             ):

+                 yield self.environment.date_substitute + line[

+                     len("Date:   ") :

+                 ]

              else:

-                 detail = ""

-             print >>out, "  %s%s" % (to_bytes(commit_oneline(commit)), to_bytes(detail))

+                 yield line

  

-         if detail_note:

-             print >>out

-             print >>out, "(*) This commit already existed in another branch; no separate mail sent"

+     def generate_email_footer(self, html_escape_val):

+         return self.expand_lines(

+             REVISION_FOOTER_TEMPLATE, html_escape_val=html_escape_val

+         )

  

-     def send_extra_emails(self):

-         total = len(self.added_commits)

+     def generate_email(self, push, body_filter=None, extra_header_values={}):

+         self._contains_diff()

+         return Change.generate_email(

+             self, push, body_filter, extra_header_values

+         )

  

-         for i, commit in enumerate(self.added_commits):

-             if not commit.id in self.detailed_commits:

-                 continue

+     def get_specific_fromaddr(self):

+         return self.environment.from_commit

+ 

+ 

+ class ReferenceChange(Change):

+     """A Change to a Git reference.

+ 

+     An abstract class representing a create, update, or delete of a

+     Git reference.  Derived classes handle specific types of reference

+     (e.g., tags vs. branches).  These classes generate the main

+     reference change email summarizing the reference change and

+     whether it caused any any commits to be added or removed.

+ 

+     ReferenceChange objects are usually created using the static

+     create() method, which has the logic to decide which derived class

+     to instantiate."""

+ 

+     REF_RE = re.compile(r"^refs\/(?P<area>[^\/]+)\/(?P<shortname>.*)$")

+ 

+     @staticmethod

+     def create(environment, oldrev, newrev, refname):

+         """Return a ReferenceChange object representing the change.

+ 

+         Return an object that represents the type of change that is being

+         made. oldrev and newrev should be SHA1s or ZEROS."""

+ 

+         old = GitObject(oldrev)

+         new = GitObject(newrev)

+         rev = new or old

+ 

+         # The revision type tells us what type the commit is, combined with

+         # the location of the ref we can decide between

+         #  - working branch

+         #  - tracking branch

+         #  - unannotated tag

+         #  - annotated tag

+         m = ReferenceChange.REF_RE.match(refname)

+         if m:

+             area = m.group("area")

+             short_refname = m.group("shortname")

+         else:

+             area = ""

+             short_refname = refname

+ 

+         if rev.type == "tag":

+             # Annotated tag:

+             klass = AnnotatedTagChange

+         elif rev.type == "commit":

+             if area == "tags":

+                 # Non-annotated tag:

+                 klass = NonAnnotatedTagChange

+             elif area == "heads":

+                 # Branch:

+                 klass = BranchChange

+             elif area == "remotes":

+                 # Tracking branch:

+                 environment.log_warning(

+                     "*** Push-update of tracking branch %r\n"

+                     "***  - incomplete email generated." % (refname,)

+                 )

+                 klass = OtherReferenceChange

+             else:

+                 # Some other reference namespace:

+                 environment.log_warning(

+                     "*** Push-update of strange reference %r\n"

+                     "***  - incomplete email generated." % (refname,)

+                 )

+                 klass = OtherReferenceChange

+         else:

+             # Anything else (is there anything else?)

+             environment.log_warning(

+                 "*** Unknown type of update to %r (%s)\n"

+                 "***  - incomplete email generated." % (refname, rev.type)

+             )

+             klass = OtherReferenceChange

+ 

+         return klass(

+             environment,

+             refname=refname,

+             short_refname=short_refname,

+             old=old,

+             new=new,

+             rev=rev,

+         )

+ 

+     def __init__(self, environment, refname, short_refname, old, new, rev):

+         Change.__init__(self, environment)

+         self.change_type = {

+             (False, True): "create",

+             (True, True): "update",

+             (True, False): "delete",

+         }[bool(old), bool(new)]

+         self.refname = refname

+         self.short_refname = short_refname

+         self.old = old

+         self.new = new

+         self.rev = rev

+         self.msgid = make_msgid()

+         self.diffopts = environment.diffopts

+         self.graphopts = environment.graphopts

+         self.logopts = environment.logopts

+         self.commitlogopts = environment.commitlogopts

+         self.showgraph = environment.refchange_showgraph

+         self.showlog = environment.refchange_showlog

+ 

+         self.header_template = REFCHANGE_HEADER_TEMPLATE

+         self.intro_template = REFCHANGE_INTRO_TEMPLATE

+         self.footer_template = FOOTER_TEMPLATE

+ 

+     def _compute_values(self):

+         values = Change._compute_values(self)

+ 

+         values["change_type"] = self.change_type

+         values["refname_type"] = self.refname_type

+         values["refname"] = self.refname

+         values["short_refname"] = self.short_refname

+         values["msgid"] = self.msgid

+         values["recipients"] = self.recipients

+         values["oldrev"] = str(self.old)

+         values["oldrev_short"] = self.old.short

+         values["newrev"] = str(self.new)

+         values["newrev_short"] = self.new.short

+ 

+         if self.old:

+             values["oldrev_type"] = self.old.type

+         if self.new:

+             values["newrev_type"] = self.new.type

+ 

+         reply_to = self.environment.get_reply_to_refchange(self)

+         if reply_to:

+             values["reply_to"] = reply_to

+ 

+         return values

+ 

+     def send_single_combined_email(self, known_added_sha1s):

+         """Determine if a combined refchange/revision email should be sent

+ 

+         If there is only a single new (non-merge) commit added by a

+         change, it is useful to combine the ReferenceChange and

+         Revision emails into one.  In such a case, return the single

+         revision; otherwise, return None.

+ 

+         This method is overridden in BranchChange."""

+ 

+         return None

+ 

+     def generate_combined_email(

+         self, push, revision, body_filter=None, extra_header_values={}

+     ):

+         """Generate an email describing this change AND specified revision.

+ 

+         Iterate over the lines (including the header lines) of an

+         email describing this change.  If body_filter is not None,

+         then use it to filter the lines that are intended for the

+         email body.

  

-             email_out = start_email()

+         The extra_header_values field is received as a dict and not as

+         **kwargs, to allow passing other keyword arguments in the

+         future (e.g. passing extra values to generate_email_intro()

  

-             if self.short_refname == 'master':

-                 branch = ""

+         This method is overridden in BranchChange."""

+ 

+         raise NotImplementedError

+ 

+     def get_subject(self):

+         template = {

+             "create": REF_CREATED_SUBJECT_TEMPLATE,

+             "update": REF_UPDATED_SUBJECT_TEMPLATE,

+             "delete": REF_DELETED_SUBJECT_TEMPLATE,

+         }[self.change_type]

+         return self.expand(template)

+ 

+     def generate_email_header(self, **extra_values):

+         if "subject" not in extra_values:

+             extra_values["subject"] = self.get_subject()

+ 

+         for line in self.expand_header_lines(

+             self.header_template, **extra_values

+         ):

+             yield line

+ 

+     def generate_email_intro(self, html_escape_val=False):

+         for line in self.expand_lines(

+             self.intro_template, html_escape_val=html_escape_val

+         ):

+             yield line

+ 

+     def generate_email_body(self, push):

+         """Call the appropriate body-generation routine.

+ 

+         Call one of generate_create_summary() /

+         generate_update_summary() / generate_delete_summary()."""

+ 

+         change_summary = {

+             "create": self.generate_create_summary,

+             "delete": self.generate_delete_summary,

+             "update": self.generate_update_summary,

+         }[self.change_type](push)

+         for line in change_summary:

+             yield line

+ 

+         for line in self.generate_revision_change_summary(push):

+             yield line

+ 

+     def generate_email_footer(self, html_escape_val):

+         return self.expand_lines(

+             self.footer_template, html_escape_val=html_escape_val

+         )

+ 

+     def generate_revision_change_graph(self, push):

+         if self.showgraph:

+             args = ["--graph"] + self.graphopts

+             for newold in ("new", "old"):

+                 has_newold = False

+                 spec = push.get_commits_spec(newold, self)

+                 for line in git_log(spec, args=args, keepends=True):

+                     if not has_newold:

+                         has_newold = True

+                         yield "\n"

+                         yield "Graph of %s commits:\n\n" % (

+                             {"new": "new", "old": "discarded"}[newold],

+                         )

+                     yield "  " + line

+                 if has_newold:

+                     yield "\n"

+ 

+     def generate_revision_change_log(self, new_commits_list):

+         if self.showlog:

+             yield "\n"

+             yield "Detailed log of new commits:\n\n"

+             for line in read_git_lines(

+                 ["log", "--no-walk"]

+                 + self.logopts

+                 + new_commits_list

+                 + ["--"],

+                 keepends=True,

+             ):

+                 yield line

+ 

+     def generate_new_revision_summary(self, tot, new_commits_list, push):

+         for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot):

+             yield line

+         for line in self.generate_revision_change_graph(push):

+             yield line

+         for line in self.generate_revision_change_log(new_commits_list):

+             yield line

+ 

+     def generate_revision_change_summary(self, push):

+         """Generate a summary of the revisions added/removed by this change."""

+ 

+         if self.new.commit_sha1 and not self.old.commit_sha1:

+             # A new reference was created.  List the new revisions

+             # brought by the new reference (i.e., those revisions that

+             # were not in the repository before this reference

+             # change).

+             sha1s = list(push.get_new_commits(self))

+             sha1s.reverse()

+             tot = len(sha1s)

+             new_revisions = [

+                 Revision(self, GitObject(sha1), num=i + 1, tot=tot)

+                 for (i, sha1) in enumerate(sha1s)

+             ]

+ 

+             if new_revisions:

+                 yield self.expand(

+                     "This %(refname_type)s includes the following new commits:\n"

+                 )

+                 yield "\n"

+                 for r in new_revisions:

+                     (sha1, subject) = r.rev.get_summary()

+                     yield r.expand(

+                         BRIEF_SUMMARY_TEMPLATE, action="new", text=subject

+                     )

+                 yield "\n"

+                 for line in self.generate_new_revision_summary(

+                     tot, [r.rev.sha1 for r in new_revisions], push

+                 ):

+                     yield line

              else:

-                 branch = "/" + self.short_refname

- 

-             total = len(self.added_commits)

-             if total > 1 and self.needs_cover_email:

-                 count_string = ": %(index)s/%(total)s" % {

-                     'index' : i + 1,

-                     'total' : total

-                 }

+                 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):

+                     yield line

+ 

+         elif self.new.commit_sha1 and self.old.commit_sha1:

+             # A reference was changed to point at a different commit.

+             # List the revisions that were removed and/or added *from

+             # that reference* by this reference change, along with a

+             # diff between the trees for its old and new values.

+ 

+             # List of the revisions that were added to the branch by

+             # this update.  Note this list can include revisions that

+             # have already had notification emails; we want such

+             # revisions in the summary even though we will not send

+             # new notification emails for them.

+             adds = list(

+                 generate_summaries(

+                     "--topo-order",

+                     "--reverse",

+                     "%s..%s" % (self.old.commit_sha1, self.new.commit_sha1),

+                 )

+             )

+ 

+             # List of the revisions that were removed from the branch

+             # by this update.  This will be empty except for

+             # non-fast-forward updates.

+             discards = list(

+                 generate_summaries(

+                     "%s..%s" % (self.new.commit_sha1, self.old.commit_sha1)

+                 )

+             )

+ 

+             if adds:

+                 new_commits_list = push.get_new_commits(self)

              else:

-                 count_string = ""

- 

-             subject = "[%(projectshort)s%(branch)s%(count_string)s] %(subject)s" % {

-                 'projectshort' : projectshort,

-                 'branch' : branch,

-                 'count_string' : count_string,

-                 'subject' : commit.subject[0:SUBJECT_MAX_SUBJECT_CHARS]

-                 }

- 

-             # If there is a cover email, it has the X-Git-OldRev/X-Git-NewRev in it

-             # for the total branch update. Without a cover email, we are conceptually

-             # breaking up the update into individual updates for each commit

-             if self.needs_cover_email:

-                 self.generate_header(email_out, subject, include_revs=False)

+                 new_commits_list = []

+             new_commits = CommitSet(new_commits_list)

+ 

+             if discards:

+                 discarded_commits = CommitSet(push.get_discarded_commits(self))

              else:

-                 parent = git.rev_parse(commit.id + "^")

-                 self.generate_header(email_out, subject,

-                                      include_revs=True,

-                                      oldrev=parent, newrev=commit.id)

- 

-             email_out.flush()

-             git.show(commit.id, M=True, stat=True, _outfile=email_out)

-             email_out.flush()

-             if not mailshortdiff:

-                 git.show(commit.id, p=True, M=True, diff_filter="ACMRTUXB", pretty="format:---", _outfile=email_out)

-             end_email()

- 

- class BranchCreation(BranchChange):

-     def get_subject(self):

-         return self.get_count_string() + "Created branch " + self.short_refname

+                 discarded_commits = CommitSet([])

+ 

+             if discards and adds:

+                 for (sha1, subject) in discards:

+                     if sha1 in discarded_commits:

+                         action = "discard"

+                     else:

+                         action = "omit"

+                     yield self.expand(

+                         BRIEF_SUMMARY_TEMPLATE,

+                         action=action,

+                         rev_short=sha1,

+                         text=subject,

+                     )

+                 for (sha1, subject) in adds:

+                     if sha1 in new_commits:

+                         action = "new"

+                     else:

+                         action = "add"

+                     yield self.expand(

+                         BRIEF_SUMMARY_TEMPLATE,

+                         action=action,

+                         rev_short=sha1,

+                         text=subject,

+                     )

+                 yield "\n"

+                 for line in self.expand_lines(NON_FF_TEMPLATE):

+                     yield line

+ 

+             elif discards:

+                 for (sha1, subject) in discards:

+                     if sha1 in discarded_commits:

+                         action = "discard"

+                     else:

+                         action = "omit"

+                     yield self.expand(

+                         BRIEF_SUMMARY_TEMPLATE,

+                         action=action,

+                         rev_short=sha1,

+                         text=subject,

+                     )

+                 yield "\n"

+                 for line in self.expand_lines(REWIND_ONLY_TEMPLATE):

+                     yield line

+ 

+             elif adds:

+                 (sha1, subject) = self.old.get_summary()

+                 yield self.expand(

+                     BRIEF_SUMMARY_TEMPLATE,

+                     action="from",

+                     rev_short=sha1,

+                     text=subject,

+                 )

+                 for (sha1, subject) in adds:

+                     if sha1 in new_commits:

+                         action = "new"

+                     else:

+                         action = "add"

+                     yield self.expand(

+                         BRIEF_SUMMARY_TEMPLATE,

+                         action=action,

+                         rev_short=sha1,

+                         text=subject,

+                     )

+ 

+             yield "\n"

+ 

+             if new_commits:

+                 for line in self.generate_new_revision_summary(

+                     len(new_commits), new_commits_list, push

+                 ):

+                     yield line

+             else:

+                 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):

+                     yield line

+                 for line in self.generate_revision_change_graph(push):

+                     yield line

+ 

+             # The diffstat is shown from the old revision to the new

+             # revision.  This is to show the truth of what happened in

+             # this change.  There's no point showing the stat from the

+             # base to the new revision because the base is effectively a

+             # random revision at this point - the user will be interested

+             # in what this revision changed - including the undoing of

+             # previous revisions in the case of non-fast-forward updates.

+             yield "\n"

+             yield "Summary of changes:\n"

+             for line in read_git_lines(

+                 ["diff-tree"]

+                 + self.diffopts

+                 + ["%s..%s" % (self.old.commit_sha1, self.new.commit_sha1)],

+                 keepends=True,

+             ):

+                 yield line

+ 

+         elif self.old.commit_sha1 and not self.new.commit_sha1:

+             # A reference was deleted.  List the revisions that were

+             # removed from the repository by this reference change.

+ 

+             sha1s = list(push.get_discarded_commits(self))

+             tot = len(sha1s)

+             discarded_revisions = [

+                 Revision(self, GitObject(sha1), num=i + 1, tot=tot)

+                 for (i, sha1) in enumerate(sha1s)

+             ]

+ 

+             if discarded_revisions:

+                 for line in self.expand_lines(DISCARDED_REVISIONS_TEMPLATE):

+                     yield line

+                 yield "\n"

+                 for r in discarded_revisions:

+                     (sha1, subject) = r.rev.get_summary()

+                     yield r.expand(

+                         BRIEF_SUMMARY_TEMPLATE, action="discard", text=subject

+                     )

+                 for line in self.generate_revision_change_graph(push):

+                     yield line

+             else:

+                 for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE):

+                     yield line

+ 

+         elif not self.old.commit_sha1 and not self.new.commit_sha1:

+             for line in self.expand_lines(NON_COMMIT_UPDATE_TEMPLATE):

+                 yield line

+ 

+     def generate_create_summary(self, push):

+         """Called for the creation of a reference."""

+ 

+         # This is a new reference and so oldrev is not valid

+         (sha1, subject) = self.new.get_summary()

+         yield self.expand(

+             BRIEF_SUMMARY_TEMPLATE, action="at", rev_short=sha1, text=subject

+         )

+         yield "\n"

+ 

+     def generate_update_summary(self, push):

+         """Called for the change of a pre-existing branch."""

+ 

+         return iter([])

+ 

+     def generate_delete_summary(self, push):

+         """Called for the deletion of any type of reference."""

+ 

+         (sha1, subject) = self.old.get_summary()

+         yield self.expand(

+             BRIEF_SUMMARY_TEMPLATE, action="was", rev_short=sha1, text=subject

+         )

+         yield "\n"

+ 

+     def get_specific_fromaddr(self):

+         return self.environment.from_refchange

+ 

+ 

+ class BranchChange(ReferenceChange):

+     refname_type = "branch"

+ 

+     def __init__(self, environment, refname, short_refname, old, new, rev):

+         ReferenceChange.__init__(

+             self,

+             environment,

+             refname=refname,

+             short_refname=short_refname,

+             old=old,

+             new=new,

+             rev=rev,

+         )

+         self.recipients = environment.get_refchange_recipients(self)

+         self._single_revision = None

+ 

+     def send_single_combined_email(self, known_added_sha1s):

+         if not self.environment.combine_when_single_commit:

+             return None

  

-     def generate_body(self, out):

-         if len(self.added_commits) > 0:

-             print >>out, s("""

- The branch '%(short_refname)s' was created.

+         # In the sadly-all-too-frequent usecase of people pushing only

+         # one of their commits at a time to a repository, users feel

+         # the reference change summary emails are noise rather than

+         # important signal.  This is because, in this particular

+         # usecase, there is a reference change summary email for each

+         # new commit, and all these summaries do is point out that

+         # there is one new commit (which can readily be inferred by

+         # the existence of the individual revision email that is also

+         # sent).  In such cases, our users prefer there to be a combined

+         # reference change summary/new revision email.

+         #

+         # So, if the change is an update and it doesn't discard any

+         # commits, and it adds exactly one non-merge commit (gerrit

+         # forces a workflow where every commit is individually merged

+         # and the git-multimail hook fired off for just this one

+         # change), then we send a combined refchange/revision email.

+         try:

+             # If this change is a reference update that doesn't discard

+             # any commits...

+             if self.change_type != "update":

+                 return None

  

- Summary of new commits:

+             if read_git_lines(

+                 ["merge-base", self.old.sha1, self.new.sha1]

+             ) != [self.old.sha1]:

+                 return None

  

- """) % {

-             'short_refname': to_bytes(self.short_refname),

-        }

+             # Check if this update introduced exactly one non-merge

+             # commit:

+ 

+             def split_line(line):

+                 """Split line into (sha1, [parent,...])."""

+ 

+                 words = line.split()

+                 return (words[0], words[1:])

+ 

+             # Get the new commits introduced by the push as a list of

+             # (sha1, [parent,...])

+             new_commits = [

+                 split_line(line)

+                 for line in read_git_lines(

+                     [

+                         "log",

+                         "-3",

+                         "--format=%H %P",

+                         "%s..%s" % (self.old.sha1, self.new.sha1),

+                     ]

+                 )

+             ]

+ 

+             if not new_commits:

+                 return None

  

-             self.generate_commit_summary(out, self.added_commits)

-         else:

-             print >>out, s("""

- The branch '%(short_refname)s' was created pointing to:

+             # If the newest commit is a merge, save it for a later check

+             # but otherwise ignore it

+             merge = None

+             tot = len(new_commits)

+             if len(new_commits[0][1]) > 1:

+                 merge = new_commits[0][0]

+                 del new_commits[0]

+ 

+             # Our primary check: we can't combine if more than one commit

+             # is introduced.  We also currently only combine if the new

+             # commit is a non-merge commit, though it may make sense to

+             # combine if it is a merge as well.

+             if not (

+                 len(new_commits) == 1

+                 and len(new_commits[0][1]) == 1

+                 and new_commits[0][0] in known_added_sha1s

+             ):

+                 return None

  

-  %(commit_oneline)s

+             # We do not want to combine revision and refchange emails if

+             # those go to separate locations.

+             rev = Revision(self, GitObject(new_commits[0][0]), 1, tot)

+             if rev.recipients != self.recipients:

+                 return None

  

- """) % {

-             'short_refname': to_bytes(self.short_refname),

-             'commit_oneline': to_bytes(commit_oneline(self.newrev))

-        }

+             # We ignored the newest commit if it was just a merge of the one

+             # commit being introduced.  But we don't want to ignore that

+             # merge commit it it involved conflict resolutions.  Check that.

+             if merge and merge != read_git_output(

+                 ["diff-tree", "--cc", merge]

+             ):

+                 return None

  

- class BranchUpdate(BranchChange):

-     def get_project_extra(self):

-         if len(self.removed_commits) > 0:

-             # In the non-fast-forward-case, the branch name is in the subject

+             # We can combine the refchange and one new revision emails

+             # into one.  Return the Revision that a combined email should

+             # be sent about.

+             return rev

+         except CommandError:

+             # Cannot determine number of commits in old..new or new..old;

+             # don't combine reference/revision emails:

              return None

+ 

+     def generate_combined_email(

+         self, push, revision, body_filter=None, extra_header_values={}

+     ):

+         values = revision.get_values()

+         if extra_header_values:

+             values.update(extra_header_values)

+         if "subject" not in extra_header_values:

+             values["subject"] = self.expand(

+                 COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE, **values

+             )

+ 

+         self._single_revision = revision

+         self._contains_diff()

+         self.header_template = COMBINED_HEADER_TEMPLATE

+         self.intro_template = COMBINED_INTRO_TEMPLATE

+         self.footer_template = COMBINED_FOOTER_TEMPLATE

+ 

+         def revision_gen_link(base_url):

+             # revision is used only to generate the body, and

+             # _content_type is set while generating headers. Get it

+             # from the BranchChange object.

+             revision._content_type = self._content_type

+             return revision.generate_browse_link(base_url)

+ 

+         self.generate_browse_link = revision_gen_link

+         for line in self.generate_email(push, body_filter, values):

+             yield line

+ 

+     def generate_email_body(self, push):

+         """Call the appropriate body generation routine.

+ 

+         If this is a combined refchange/revision email, the special logic

+         for handling this combined email comes from this function.  For

+         other cases, we just use the normal handling."""

+ 

+         # If self._single_revision isn't set; don't override

+         if not self._single_revision:

+             for line in super(BranchChange, self).generate_email_body(push):

+                 yield line

+             return

+ 

+         # This is a combined refchange/revision email; we first provide

+         # some info from the refchange portion, and then call the revision

+         # generate_email_body function to handle the revision portion.

+         adds = list(

+             generate_summaries(

+                 "--topo-order",

+                 "--reverse",

+                 "%s..%s" % (self.old.commit_sha1, self.new.commit_sha1),

+             )

+         )

+ 

+         yield self.expand(

+             "The following commit(s) were added to %(refname)s by this push:\n"

+         )

+         for (sha1, subject) in adds:

+             yield self.expand(

+                 BRIEF_SUMMARY_TEMPLATE,

+                 action="new",

+                 rev_short=sha1,

+                 text=subject,

+             )

+ 

+         yield self._single_revision.rev.short + " is described below\n"

+         yield "\n"

+ 

+         for line in self._single_revision.generate_email_body(push):

+             yield line

+ 

+ 

+ class AnnotatedTagChange(ReferenceChange):

+     refname_type = "annotated tag"

+ 

+     def __init__(self, environment, refname, short_refname, old, new, rev):

+         ReferenceChange.__init__(

+             self,

+             environment,

+             refname=refname,

+             short_refname=short_refname,

+             old=old,

+             new=new,

+             rev=rev,

+         )

+         self.recipients = environment.get_announce_recipients(self)

+         self.show_shortlog = environment.announce_show_shortlog

+ 

+     ANNOTATED_TAG_FORMAT = (

+         "%(*objectname)\n" "%(*objecttype)\n" "%(taggername)\n" "%(taggerdate)"

+     )

+ 

+     def describe_tag(self, push):

+         """Describe the new value of an annotated tag."""

+ 

+         # Use git for-each-ref to pull out the individual fields from

+         # the tag

+         [tagobject, tagtype, tagger, tagged] = read_git_lines(

+             [

+                 "for-each-ref",

+                 "--format=%s" % (self.ANNOTATED_TAG_FORMAT,),

+                 self.refname,

+             ]

+         )

+ 

+         yield self.expand(

+             BRIEF_SUMMARY_TEMPLATE,

+             action="tagging",

+             rev_short=tagobject,

+             text="(%s)" % (tagtype,),

+         )

+         if tagtype == "commit":

+             # If the tagged object is a commit, then we assume this is a

+             # release, and so we calculate which tag this tag is

+             # replacing

+             try:

+                 prevtag = read_git_output(

+                     ["describe", "--abbrev=0", "%s^" % (self.new,)]

+                 )

+             except CommandError:

+                 prevtag = None

+             if prevtag:

+                 yield " replaces %s\n" % (prevtag,)

          else:

-             if self.short_refname == 'master':

-                 # Not saying 'master' all over the place reduces clutter

-                 return None

+             prevtag = None

+             yield "  length %s bytes\n" % (

+                 read_git_output(["cat-file", "-s", tagobject]),

+             )

+ 

+         yield "      by %s\n" % (tagger,)

+         yield "      on %s\n" % (tagged,)

+         yield "\n"

+ 

+         # Show the content of the tag message; this might contain a

+         # change log or release notes so is worth displaying.

+         yield LOGBEGIN

+         contents = list(

+             read_git_lines(["cat-file", "tag", self.new.sha1], keepends=True)

+         )

+         contents = contents[contents.index("\n") + 1 :]

+         if contents and contents[-1][-1:] != "\n":

+             contents.append("\n")

+         for line in contents:

+             yield line

+ 

+         if self.show_shortlog and tagtype == "commit":

+             # Only commit tags make sense to have rev-list operations

+             # performed on them

+             yield "\n"

+             if prevtag:

+                 # Show changes since the previous release

+                 revlist = read_git_output(

+                     [

+                         "rev-list",

+                         "--pretty=short",

+                         "%s..%s" % (prevtag, self.new),

+                     ],

+                     keepends=True,

+                 )

              else:

-                 return self.short_refname

+                 # No previous tag, show all the changes since time

+                 # began

+                 revlist = read_git_output(

+                     ["rev-list", "--pretty=short", "%s" % (self.new,)],

+                     keepends=True,

+                 )

+             for line in read_git_lines(

+                 ["shortlog"], input=revlist, keepends=True

+             ):

+                 yield line

  

-     def get_subject(self):

-         if len(self.removed_commits) > 0:

-             return self.get_count_string() + "Non-fast-forward update to branch " + self.short_refname

-         else:

-             # We want something for useful for the subject than "Updates to branch spiffy-stuff".

-             # The common case where we have a cover-letter for a fast-forward branch

-             # update is a merge. So we try to get:

-             #

-             #  [myproject/spiffy-stuff] (18 commits) ...Merge branch master

-             #

-             last_commit = self.added_commits[-1]

-             if len(self.added_commits) > 1:

-                 return self.get_count_string() + "..." + last_commit.subject[0:SUBJECT_MAX_SUBJECT_CHARS]

-             else:

-                 # The ... indicates we are only showing one of many, don't need it for a single commit

-                 return last_commit.subject[0:SUBJECT_MAX_SUBJECT_CHARS]

+         yield LOGEND

+         yield "\n"

  

-     def generate_body_normal(self, out):

-         print >>out, s("""

- Summary of changes:

+     def generate_create_summary(self, push):

+         """Called for the creation of an annotated tag."""

  

- """)

+         for line in self.expand_lines(TAG_CREATED_TEMPLATE):

+             yield line

  

-         self.generate_commit_summary(out, self.added_commits)

+         for line in self.describe_tag(push):

+             yield line

  

-     def generate_body_non_fast_forward(self, out):

-         print >>out, s("""

- The branch '%(short_refname)s' was changed in a way that was not a fast-forward update.

- NOTE: This may cause problems for people pulling from the branch. For more information,

- please see:

+     def generate_update_summary(self, push):

+         """Called for the update of an annotated tag.

  

-  http://live.gnome.org/Git/Help/NonFastForward

+         This is probably a rare event and may not even be allowed."""

  

- Commits removed from the branch:

+         for line in self.expand_lines(TAG_UPDATED_TEMPLATE):

+             yield line

  

- """) % {

-             'short_refname': to_bytes(self.short_refname),

-        }

+         for line in self.describe_tag(push):

+             yield line

  

-         self.generate_commit_summary(out, self.removed_commits, show_details=False)

+     def generate_delete_summary(self, push):

+         """Called when a non-annotated reference is updated."""

  

-         print >>out, s("""

+         for line in self.expand_lines(TAG_DELETED_TEMPLATE):

+             yield line

  

- Commits added to the branch:

+         yield self.expand("   tag was  %(oldrev_short)s\n")

+         yield "\n"

  

- """)

-         self.generate_commit_summary(out, self.added_commits)

  

-     def generate_body(self, out):

-         if len(self.removed_commits) == 0:

-             self.generate_body_normal(out)

-         else:

-             self.generate_body_non_fast_forward(out)

+ class NonAnnotatedTagChange(ReferenceChange):

+     refname_type = "tag"

  

- class BranchDeletion(RefChange):

-     def get_subject(self):

-         return "Deleted branch " + self.short_refname

- 

-     def generate_body(self, out):

-         print >>out, s("""

- The branch '%(short_refname)s' was deleted.

- """) % {

-             'short_refname': to_bytes(self.short_refname),

-        }

- 

- # ========================

- 

- class AnnotatedTagChange(RefChange):

-     def __init__(self, *args):

-         RefChange.__init__(self, *args)

- 

-     def prepare(self):

-         # Resolve tag to commit

-         if self.oldrev:

-             self.old_commit_id = git.rev_parse(self.oldrev + "^{commit}")

- 

-         if self.newrev:

-             self.parse_tag_object(self.newrev)

-         else:

-             self.parse_tag_object(self.oldrev)

- 

-     # Parse information out of the tag object

-     def parse_tag_object(self, revision):

-         message_lines = []

-         in_message = False

- 

-         # A bit of paranoia if we fail at parsing; better to make the failure

-         # visible than just silently skip Tagger:/Date:.

-         self.tagger = "unknown <unknown@example.com>"

-         self.date = "at an unknown time"

- 

-         self.have_signature = False

-         for line in git.cat_file(revision, p=True, _split_lines=True):

-             if in_message:

-                 # Nobody is going to verify the signature by extracting it

-                 # from the email, so strip it, and remember that we saw it

-                 # by saying 'signed tag'

-                 if re.match(r'-----BEGIN PGP SIGNATURE-----', line):

-                     self.have_signature = True

-                     break

-                 message_lines.append(line)

-             else:

-                 if line.strip() == "":

-                     in_message = True

-                     continue

-                 # I don't know what a more robust rule is for dividing the

-                 # name and date, other than maybe looking explicitly for a

-                 # RFC 822 date. This seems to work pretty well

-                 m = re.match(r"tagger\s+([^>]*>)\s*(.*)", line)

-                 if m:

-                     self.tagger = m.group(1)

-                     self.date = m.group(2)

-                     continue

-         self.message = "\n".join(["    " + line for line in message_lines])

+     def __init__(self, environment, refname, short_refname, old, new, rev):

+         ReferenceChange.__init__(

+             self,

+             environment,

+             refname=refname,

+             short_refname=short_refname,

+             old=old,

+             new=new,

+             rev=rev,

+         )

+         self.recipients = environment.get_refchange_recipients(self)

  

-     # Outputs information about the new tag

-     def generate_tag_info(self, out):

+     def generate_create_summary(self, push):

+         """Called for the creation of an annotated tag."""

  

-         print >>out, s("""

- Tagger: %(tagger)s

- Date: %(date)s

+         for line in self.expand_lines(TAG_CREATED_TEMPLATE):

+             yield line

  

- %(message)s

+     def generate_update_summary(self, push):

+         """Called when a non-annotated reference is updated."""

  

- """) % {

-             'tagger': to_bytes(self.tagger),

-             'date': to_bytes(self.date),

-             'message': to_bytes(self.message),

-        }

+         for line in self.expand_lines(TAG_UPDATED_TEMPLATE):

+             yield line

  

-         # We take the creation of an annotated tag as being a "mini-release-announcement"

-         # and show a 'git shortlog' of the changes since the last tag that was an

-         # ancestor of the new tag.

-         last_tag = None

-         try:

-             # A bit of a hack to get that previous tag

-             last_tag = git.describe(self.newrev+"^", abbrev='0', _quiet=True)

-         except CalledProcessError:

-             # Assume that this means no older tag

-             pass

+     def generate_delete_summary(self, push):

+         """Called when a non-annotated reference is updated."""

+ 

+         for line in self.expand_lines(TAG_DELETED_TEMPLATE):

+             yield line

+ 

+         for line in ReferenceChange.generate_delete_summary(self, push):

+             yield line

+ 

+ 

+ class OtherReferenceChange(ReferenceChange):

+     refname_type = "reference"

+ 

+     def __init__(self, environment, refname, short_refname, old, new, rev):

+         # We use the full refname as short_refname, because otherwise

+         # the full name of the reference would not be obvious from the

+         # text of the email.

+         ReferenceChange.__init__(

+             self,

+             environment,

+             refname=refname,

+             short_refname=refname,

+             old=old,

+             new=new,

+             rev=rev,

+         )

+         self.recipients = environment.get_refchange_recipients(self)

  

-         if last_tag:

-             revision_range = last_tag + ".." + self.newrev

-             print >>out, s("""

- Changes since the last tag '%(last_tag)s':

  

- """) % {

-                 'last_tag': to_bytes(last_tag)

-       }

+ class Mailer(object):

+     """An object that can send emails."""

+ 

+     def __init__(self, environment):

+         self.environment = environment

+ 

+     def send(self, lines, to_addrs):

+         """Send an email consisting of lines.

+ 

+         lines must be an iterable over the lines constituting the

+         header and body of the email.  to_addrs is a list of recipient

+         addresses (can be needed even if lines already contains a

+         "To:" field).  It can be either a string (comma-separated list

+         of email addresses) or a Python list of individual email

+         addresses.

+ 

+         """

+ 

+         raise NotImplementedError()

+ 

+ 

+ class SendMailer(Mailer):

+     """Send emails using 'sendmail -oi -t'."""

+ 

+     SENDMAIL_CANDIDATES = ["/usr/sbin/sendmail", "/usr/lib/sendmail"]

+ 

+     @staticmethod

+     def find_sendmail():

+         for path in SendMailer.SENDMAIL_CANDIDATES:

+             if os.access(path, os.X_OK):

+                 return path

+         else:

+             raise ConfigurationException(

+                 "No sendmail executable found.  "

+                 "Try setting multimailhook.sendmailCommand."

+             )

+ 

+     def __init__(self, environment, command=None, envelopesender=None):

+         """Construct a SendMailer instance.

+ 

+         command should be the command and arguments used to invoke

+         sendmail, as a list of strings.  If an envelopesender is

+         provided, it will also be passed to the command, via '-f

+         envelopesender'."""

+         super(SendMailer, self).__init__(environment)

+         if command:

+             self.command = command[:]

          else:

-             revision_range = self.newrev

-             print >>out, s("""

- Changes:

+             self.command = [self.find_sendmail(), "-oi", "-t"]

  

- """)

-         out.write(to_bytes(git.shortlog(revision_range)))

-         out.write("\n")

+         if envelopesender:

+             self.command.extend(["-f", envelopesender])

+ 

+     def send(self, lines, to_addrs):

+         try:

+             p = subprocess.Popen(self.command, stdin=subprocess.PIPE)

+         except OSError:

+             self.environment.get_logger().error(

+                 "*** Cannot execute command: %s\n" % " ".join(self.command)

+                 + "*** %s\n" % sys.exc_info()[1]

+                 + '*** Try setting multimailhook.mailer to "smtp"\n'

+                 + "*** to send emails without using the sendmail command.\n"

+             )

+             sys.exit(1)

+         try:

+             lines = (str_to_bytes(line) for line in lines)

+             p.stdin.writelines(lines)

+         except Exception:

+             self.environment.get_logger().error(

+                 "*** Error while generating commit email\n"

+                 "***  - mail sending aborted.\n"

+             )

+             if hasattr(p, "terminate"):

+                 # subprocess.terminate() is not available in Python 2.4

+                 p.terminate()

+             else:

+                 import signal

  

-     def get_tag_type(self):

-         if self.have_signature:

-             return 'signed tag'

+                 os.kill(p.pid, signal.SIGTERM)

+             raise

          else:

-             return 'unsigned tag'

+             p.stdin.close()

+             retcode = p.wait()

+             if retcode:

+                 raise CommandError(self.command, retcode)

+ 

+ 

+ class SMTPMailer(Mailer):

+     """Send emails using Python's smtplib."""

+ 

+     def __init__(

+         self,

+         environment,

+         envelopesender,

+         smtpserver,

+         smtpservertimeout=10.0,

+         smtpserverdebuglevel=0,

+         smtpencryption="none",

+         smtpuser="",

+         smtppass="",

+         smtpcacerts="",

+     ):

+         super(SMTPMailer, self).__init__(environment)

+         if not envelopesender:

+             self.environment.get_logger().error(

+                 "fatal: git_multimail: cannot use SMTPMailer without a sender address.\n"

+                 "please set either multimailhook.envelopeSender or user.email\n"

+             )

+             sys.exit(1)

+         if smtpencryption == "ssl" and not (smtpuser and smtppass):

+             raise ConfigurationException(

+                 "Cannot use SMTPMailer with security option ssl "

+                 "without options username and password."

+             )

+         self.envelopesender = envelopesender

+         self.smtpserver = smtpserver

+         self.smtpservertimeout = smtpservertimeout

+         self.smtpserverdebuglevel = smtpserverdebuglevel

+         self.security = smtpencryption

+         self.username = smtpuser

+         self.password = smtppass

+         self.smtpcacerts = smtpcacerts

+         try:

  

- class AnnotatedTagCreation(AnnotatedTagChange):

-     def get_subject(self):

-         return "Created tag " + self.short_refname

+             def call(klass, server, timeout):

+                 try:

+                     return klass(server, timeout=timeout)

+                 except TypeError:

+                     # Old Python versions do not have timeout= argument.

+                     return klass(server)

+ 

+             if self.security == "none":

+                 self.smtp = call(

+                     smtplib.SMTP,

+                     self.smtpserver,

+                     timeout=self.smtpservertimeout,

+                 )

+             elif self.security == "ssl":

+                 if self.smtpcacerts:

+                     raise smtplib.SMTPException(

+                         "Checking certificate is not supported for ssl, prefer starttls"

+                     )

+                 self.smtp = call(

+                     smtplib.SMTP_SSL,

+                     self.smtpserver,

+                     timeout=self.smtpservertimeout,

+                 )

+             elif self.security == "tls":

+                 if "ssl" not in sys.modules:

+                     self.environment.get_logger().error(

+                         "*** Your Python version does not have the ssl library installed\n"

+                         "*** smtpEncryption=tls is not available.\n"

+                         "*** Either upgrade Python to 2.6 or later\n"

+                         "    or use git_multimail.py version 1.2.\n"

+                     )

+                 if ":" not in self.smtpserver:

+                     self.smtpserver += ":587"  # default port for TLS

+                 self.smtp = call(

+                     smtplib.SMTP,

+                     self.smtpserver,

+                     timeout=self.smtpservertimeout,

+                 )

+                 # start: ehlo + starttls

+                 # equivalent to

+                 #     self.smtp.ehlo()

+                 #     self.smtp.starttls()

+                 # with acces to the ssl layer

+                 self.smtp.ehlo()

+                 if not self.smtp.has_extn("starttls"):

+                     raise smtplib.SMTPException(

+                         "STARTTLS extension not supported by server"

+                     )

+                 resp, reply = self.smtp.docmd("STARTTLS")

+                 if resp != 220:

+                     raise smtplib.SMTPException(

+                         "Wrong answer to the STARTTLS command"

+                     )

+                 if self.smtpcacerts:

+                     self.smtp.sock = ssl.wrap_socket(

+                         self.smtp.sock,

+                         ca_certs=self.smtpcacerts,

+                         cert_reqs=ssl.CERT_REQUIRED,

+                     )

+                 else:

+                     self.smtp.sock = ssl.wrap_socket(

+                         self.smtp.sock, cert_reqs=ssl.CERT_NONE

+                     )

+                     self.environment.get_logger().error(

+                         "*** Warning, the server certificat is not verified (smtp) ***\n"

+                         "***          set the option smtpCACerts                   ***\n"

+                     )

+                 if not hasattr(self.smtp.sock, "read"):

+                     # using httplib.FakeSocket with Python 2.5.x or earlier

+                     self.smtp.sock.read = self.smtp.sock.recv

+                 self.smtp.file = smtplib.SSLFakeFile(self.smtp.sock)

+                 self.smtp.helo_resp = None

+                 self.smtp.ehlo_resp = None

+                 self.smtp.esmtp_features = {}

+                 self.smtp.does_esmtp = 0

+                 # end:   ehlo + starttls

+                 self.smtp.ehlo()

+             else:

+                 sys.stdout.write(

+                     "*** Error: Control reached an invalid option. ***"

+                 )

+                 sys.exit(1)

+             if self.smtpserverdebuglevel > 0:

+                 sys.stdout.write(

+                     "*** Setting debug on for SMTP server connection (%s) ***\n"

+                     % self.smtpserverdebuglevel

+                 )

+                 self.smtp.set_debuglevel(self.smtpserverdebuglevel)

+         except Exception:

+             self.environment.get_logger().error(

+                 "*** Error establishing SMTP connection to %s ***\n"

+                 "*** %s\n" % (self.smtpserver, sys.exc_info()[1])

+             )

+             sys.exit(1)

+ 

+     def __del__(self):

+         if hasattr(self, "smtp"):

+             self.smtp.quit()

+             del self.smtp

+ 

+     def send(self, lines, to_addrs):

+         try:

+             if self.username or self.password:

+                 self.smtp.login(self.username, self.password)

+             msg = "".join(lines)

+             # turn comma-separated list into Python list if needed.

+             if is_string(to_addrs):

+                 to_addrs = [

+                     email for (name, email) in getaddresses([to_addrs])

+                 ]

+             self.smtp.sendmail(self.envelopesender, to_addrs, msg)

+         except smtplib.SMTPResponseException:

+             err = sys.exc_info()[1]

+             self.environment.get_logger().error(

+                 "*** Error sending email ***\n"

+                 "*** Error %d: %s\n"

+                 % (err.smtp_code, bytes_to_str(err.smtp_error))

+             )

+             try:

+                 smtp = self.smtp

+                 # delete the field before quit() so that in case of

+                 # error, self.smtp is deleted anyway.

+                 del self.smtp

+                 smtp.quit()

+             except:

+                 self.environment.get_logger().error(

+                     "*** Error closing the SMTP connection ***\n"

+                     "*** Exiting anyway ... ***\n"

+                     "*** %s\n" % sys.exc_info()[1]

+                 )

+             sys.exit(1)

+ 

+ 

+ class OutputMailer(Mailer):

+     """Write emails to an output stream, bracketed by lines of '=' characters.

+ 

+     This is intended for debugging purposes."""

+ 

+     SEPARATOR = "=" * 75 + "\n"

+ 

+     def __init__(self, f):

+         self.f = f

+ 

+     def send(self, lines, to_addrs):

+         write_str(self.f, self.SEPARATOR)

+         for line in lines:

+             write_str(self.f, line)

+         write_str(self.f, self.SEPARATOR)

+ 

+ 

+ def get_git_dir():

+     """Determine GIT_DIR.

+ 

+     Determine GIT_DIR either from the GIT_DIR environment variable or

+     from the working directory, using Git's usual rules."""

  

-     def generate_body(self, out):

-         print >>out, s("""

- The %(tag_type)s '%(short_refname)s' was created.

+     try:

+         return read_git_output(["rev-parse", "--git-dir"])

+     except CommandError:

+         sys.stderr.write("fatal: git_multimail: not in a git directory\n")

+         sys.exit(1)

  

- """) % {

-             'tag_type': to_bytes(self.get_tag_type()),

-             'short_refname': to_bytes(self.short_refname),

-        }

-         self.generate_tag_info(out)

  

- class AnnotatedTagDeletion(AnnotatedTagChange):

-     def get_subject(self):

-         return "Deleted tag " + self.short_refname

+ class Environment(object):

+     """Describes the environment in which the push is occurring.

  

-     def generate_body(self, out):

-         print >>out, s("""

- The %(tag_type)s '%(short_refname)s' was deleted. It previously pointed to:

+     An Environment object encapsulates information about the local

+     environment.  For example, it knows how to determine:

  

-  %(old_commit_oneline)s

- """) % {

-             'tag_type': to_bytes(self.get_tag_type()),

-             'short_refname': to_bytes(self.short_refname),

-             'old_commit_oneline': to_bytes(commit_oneline(self.old_commit_id)),

-        }

+     * the name of the repository to which the push occurred

  

- class AnnotatedTagUpdate(AnnotatedTagChange):

-     def get_subject(self):

-         return "Updated tag " + self.short_refname

+     * what user did the push

  

-     def generate_body(self, out):

-         print >>out, s("""

- The tag '%(short_refname)s' was replaced with a new tag. It previously

- pointed to:

+     * what users want to be informed about various types of changes.

  

-  %(old_commit_oneline)s

+     An Environment object is expected to have the following methods:

  

- NOTE: People pulling from the repository will not get the new tag.

- For more information, please see:

+         get_repo_shortname()

  

-  http://live.gnome.org/Git/Help/TagUpdates

+             Return a short name for the repository, for display

+             purposes.

  

- New tag information:

+         get_repo_path()

  

- """) % {

-             'short_refname': to_bytes(self.short_refname),

-             'old_commit_oneline': to_bytes(commit_oneline(self.old_commit_id)),

-        }

-         self.generate_tag_info(out)

+             Return the absolute path to the Git repository.

  

- # ========================

+         get_emailprefix()

  

- class LightweightTagCreation(RefChange):

-     def get_subject(self):

-         return "Created tag " + self.short_refname

+             Return a string that will be prefixed to every email's

+             subject.

  

-     def generate_body(self, out):

-         print >>out, s("""

- The lightweight tag '%(short_refname)s' was created pointing to:

+         get_pusher()

  

-  %(commit_oneline)s

- """) % {

-             'short_refname': to_bytes(self.short_refname),

-             'commit_oneline': to_bytes(commit_oneline(self.newrev))

-        }

+             Return the username of the person who pushed the changes.

+             This value is used in the email body to indicate who

+             pushed the change.

  

- class LightweightTagDeletion(RefChange):

-     def get_subject(self):

-         return "Deleted tag " + self.short_refname

+         get_pusher_email() (may return None)

  

-     def generate_body(self, out):

-         print >>out, s("""

- The lighweight tag '%(short_refname)s' was deleted. It previously pointed to:

+             Return the email address of the person who pushed the

+             changes.  The value should be a single RFC 2822 email

+             address as a string; e.g., "Joe User <user@example.com>"

+             if available, otherwise "user@example.com".  If set, the

+             value is used as the Reply-To address for refchange

+             emails.  If it is impossible to determine the pusher's

+             email, this attribute should be set to None (in which case

+             no Reply-To header will be output).

  

-  %(commit_oneline)s

- """) % {

-             'short_refname': to_bytes(self.short_refname),

-             'commit_oneline': to_bytes(commit_oneline(self.oldrev)),

-        }

+         get_sender()

  

- class LightweightTagUpdate(RefChange):

-     def get_subject(self):

-         return "Updated tag " + self.short_refname

+             Return the address to be used as the 'From' email address

+             in the email envelope.

  

-     def generate_body(self, out):

-         print >>out, s("""

- The lightweight tag '%(short_refname)s' was updated to point to:

+         get_fromaddr(change=None)

  

-  %(commit_oneline)s

+             Return the 'From' email address used in the email 'From:'

+             headers.  If the change is known when this function is

+             called, it is passed in as the 'change' parameter.  (May

+             be a full RFC 2822 email address like 'Joe User

+             <user@example.com>'.)

  

- It previously pointed to:

+         get_administrator()

  

-  %(old_commit_oneline)s

+             Return the name and/or email of the repository

+             administrator.  This value is used in the footer as the

+             person to whom requests to be removed from the

+             notification list should be sent.  Ideally, it should

+             include a valid email address.

  

- NOTE: People pulling from the repository will not get the new tag.

- For more information, please see:

+         get_reply_to_refchange()

+         get_reply_to_commit()

  

-  http://live.gnome.org/Git/Help/TagUpdates

- """) % {

-             'short_refname': to_bytes(self.short_refname),

-             'commit_oneline': to_bytes(commit_oneline(self.newrev)),

-             'old_commit_oneline': to_bytes(commit_oneline(self.oldrev)),

-        }

+             Return the address to use in the email "Reply-To" header,

+             as a string.  These can be an RFC 2822 email address, or

+             None to omit the "Reply-To" header.

+             get_reply_to_refchange() is used for refchange emails;

+             get_reply_to_commit() is used for individual commit

+             emails.

  

- # ========================

+         get_ref_filter_regex()

  

- class InvalidRefDeletion(RefChange):

-     def get_subject(self):

-         return "Deleted invalid ref " + self.refname

+             Return a tuple -- a compiled regex, and a boolean indicating

+             whether the regex picks refs to include (if False, the regex

+             matches on refs to exclude).

  

-     def generate_body(self, out):

-         print >>out, s("""

- The ref '%(refname)s' was deleted. It previously pointed nowhere.

- """) % {

-             'refname': to_bytes(self.refname),

-        }

+         get_default_ref_ignore_regex()

  

- # ========================

+             Return a regex that should be ignored for both what emails

+             to send and when computing what commits are considered new

+             to the repository.  Default is "^refs/notes/".

  

- class MiscChange(RefChange):

-     def __init__(self, refname, oldrev, newrev, message):

-         RefChange.__init__(self, refname, oldrev, newrev)

-         self.message = message

+         get_max_subject_length()

  

- class MiscCreation(MiscChange):

-     def get_subject(self):

-         return "Unexpected: Created " + self.refname

+             Return an int giving the maximal length for the subject

+             (git log --oneline).

  

-     def generate_body(self, out):

-         print >>out, s("""

- The ref '%(refname)s' was created pointing to:

+     They should also define the following attributes:

  

-  %(newrev)s

+         announce_show_shortlog (bool)

  

- This is unexpected because:

+             True iff announce emails should include a shortlog.

  

-  %(message)s

- """) % {

-             'refname': to_bytes(self.refname),

-             'newrev': to_bytes(self.newrev),

-             'message': to_bytes(self.message),

-       }

+         commit_email_format (string)

  

- class MiscDeletion(MiscChange):

-     def get_subject(self):

-         return "Unexpected: Deleted " + self.refname

+             If "html", generate commit emails in HTML instead of plain text

+             used by default.

  

-     def generate_body(self, out):

-         print >>out, s("""

- The ref '%(refname)s' was deleted. It previously pointed to:

+         html_in_intro (bool)

+         html_in_footer (bool)

  

-  %(oldrev)s

+             When generating HTML emails, the introduction (respectively,

+             the footer) will be HTML-escaped iff html_in_intro (respectively,

+             the footer) is true. When false, only the values used to expand

+             the template are escaped.

  

- This is unexpected because:

+         refchange_showgraph (bool)

  

-  %(message)s

- """) % {

-             'refname': to_bytes(self.refname),

-             'oldrev': to_bytes(self.oldrev),

-             'message': to_bytes(self.message),

-       }

+             True iff refchanges emails should include a detailed graph.

  

- class MiscUpdate(MiscChange):

-     def get_subject(self):

-         return "Unexpected: Updated " + self.refname

+         refchange_showlog (bool)

  

-     def generate_body(self, out):

-         print >>out, s("""

- The ref '%(refname)s' was updated from:

+             True iff refchanges emails should include a detailed log.

  

-  %(newrev)s

+         diffopts (list of strings)

  

- To:

+             The options that should be passed to 'git diff' for the

+             summary email.  The value should be a list of strings

+             representing words to be passed to the command.

  

-  %(oldrev)s

+         graphopts (list of strings)

  

- This is unexpected because:

+             Analogous to diffopts, but contains options passed to

+             'git log --graph' when generating the detailed graph for

+             a set of commits (see refchange_showgraph)

  

-  %(message)s

- """) % {

-             'refname': to_bytes(self.refname),

-             'oldrev': to_bytes(self.oldrev),

-             'newrev': to_bytes(self.newrev),

-             'message': to_bytes(self.message),

-       }

+         logopts (list of strings)

  

- # ========================

+             Analogous to diffopts, but contains options passed to

+             'git log' when generating the detailed log for a set of

+             commits (see refchange_showlog)

  

- def make_change(oldrev, newrev, refname):

-     refname = refname

+         commitlogopts (list of strings)

  

-     # Canonicalize

-     oldrev = git.rev_parse(oldrev)

-     newrev = git.rev_parse(newrev)

+             The options that should be passed to 'git log' for each

+             commit mail.  The value should be a list of strings

+             representing words to be passed to the command.

  

-     # Replacing the null revision with None makes it easier for us to test

-     # in subsequent code

+         date_substitute (string)

  

-     if re.match(r'^0+$', oldrev):

-         oldrev = None

-     else:

-         oldrev = oldrev

+             String to be used in substitution for 'Date:' at start of

+             line in the output of 'git log'.

  

-     if re.match(r'^0+$', newrev):

-         newrev = None

-     else:

-         newrev = newrev

- 

-     # Figure out what we are doing to the ref

- 

-     if oldrev == None and newrev != None:

-         change_type = CREATE

-         target = newrev

-     elif oldrev != None and newrev == None:

-         change_type = DELETE

-         target = oldrev

-     elif oldrev != None and newrev != None:

-         change_type = UPDATE

-         target = newrev

-     else:

-         return InvalidRefDeletion(refname, oldrev, newrev)

+         quiet (bool)

+             On success do not write to stderr

+ 

+         stdout (bool)

+             Write email to stdout rather than emailing. Useful for debugging

+ 

+         combine_when_single_commit (bool)

+ 

+             True if a combined email should be produced when a single

+             new commit is pushed to a branch, False otherwise.

+ 

+         from_refchange, from_commit (strings)

+ 

+             Addresses to use for the From: field for refchange emails

+             and commit emails respectively.  Set from

+             multimailhook.fromRefchange and multimailhook.fromCommit

+             by ConfigEnvironmentMixin.

+ 

+         log_file, error_log_file, debug_log_file (string)

+ 

+             Name of a file to which logs should be sent.

+ 

+         verbose (int)

+ 

+             How verbose the system should be.

+             - 0 (default): show info, errors, ...

+             - 1 : show basic debug info

+     """

+ 

+     REPO_NAME_RE = re.compile(r"^(?P<name>.+?)(?:\.git)$")

+ 

+     def __init__(self, osenv=None):

+         self.osenv = osenv or os.environ

+         self.announce_show_shortlog = False

+         self.commit_email_format = "text"

+         self.html_in_intro = False

+         self.html_in_footer = False

+         self.commitBrowseURL = None

+         self.maxcommitemails = 500

+         self.diffopts = ["--stat", "--summary", "--find-copies-harder"]

+         self.graphopts = ["--oneline", "--decorate"]

+         self.logopts = []

+         self.refchange_showgraph = False

+         self.refchange_showlog = False

+         self.commitlogopts = ["-C", "--stat", "-p", "--cc"]

+         self.date_substitute = "AuthorDate: "

+         self.quiet = False

+         self.stdout = False

+         self.combine_when_single_commit = True

+         self.logger = None

+ 

+         self.COMPUTED_KEYS = [

+             "administrator",

+             "charset",

+             "emailprefix",

+             "pusher",

+             "pusher_email",

+             "repo_path",

+             "repo_shortname",

+             "sender",

+         ]

+ 

+         self._values = None

+ 

+     def get_logger(self):

+         """Get (possibly creates) the logger associated to this environment."""

+         if self.logger is None:

+             self.logger = Logger(self)

+         return self.logger

+ 

+     def get_repo_shortname(self):

+         """Use the last part of the repo path, with ".git" stripped off if present."""

+ 

+         basename = os.path.basename(os.path.abspath(self.get_repo_path()))

+         m = self.REPO_NAME_RE.match(basename)

+         if m:

+             return m.group("name")

+         else:

+             return basename

+ 

+     def get_pusher(self):

+         raise NotImplementedError()

+ 

+     def get_pusher_email(self):

+         return None

  

-     object_type = git.cat_file(target, t=True)

+     def get_fromaddr(self, change=None):

+         config = Config("user")

+         fromname = config.get("name", default="")

+         fromemail = config.get("email", default="")

+         if fromemail:

+             return formataddr([fromname, fromemail])

+         return self.get_sender()

  

-     # And then create the right type of change object

+     def get_administrator(self):

+         return "the administrator of this repository"

  

-     # Closing the arguments like this simplifies the following code

-     def make(cls, *args):

-         return cls(refname, oldrev, newrev, *args)

+     def get_emailprefix(self):

+         return ""

  

-     def make_misc_change(message):

-         if change_type == CREATE:

-             return make(MiscCreation, message)

-         elif change_type == DELETE:

-             return make(MiscDeletion, message)

+     def get_repo_path(self):

+         if read_git_output(["rev-parse", "--is-bare-repository"]) == "true":

+             path = get_git_dir()

          else:

-             return make(MiscUpdate, message)

+             path = read_git_output(["rev-parse", "--show-toplevel"])

+         return os.path.abspath(path)

+ 

+     def get_charset(self):

+         return CHARSET

+ 

+     def get_values(self):

+         """Return a dictionary {keyword: expansion} for this Environment.

+ 

+         This method is called by Change._compute_values().  The keys

+         in the returned dictionary are available to be used in any of

+         the templates.  The dictionary is created by calling

+         self.get_NAME() for each of the attributes named in

+         COMPUTED_KEYS and recording those that do not return None.

+         The return value is always a new dictionary."""

+ 

+         if self._values is None:

+             values = {"": ""}  # %()s expands to the empty string.

+ 

+             for key in self.COMPUTED_KEYS:

+                 value = getattr(self, "get_%s" % (key,))()

+                 if value is not None:

+                     values[key] = value

+ 

+             self._values = values

+ 

+         return self._values.copy()

+ 

+     def get_refchange_recipients(self, refchange):

+         """Return the recipients for notifications about refchange.

+ 

+         Return the list of email addresses to which notifications

+         about the specified ReferenceChange should be sent."""

+ 

+         raise NotImplementedError()

+ 

+     def get_announce_recipients(self, annotated_tag_change):

+         """Return the recipients for notifications about annotated_tag_change.

+ 

+         Return the list of email addresses to which notifications

+         about the specified AnnotatedTagChange should be sent."""

+ 

+         raise NotImplementedError()

+ 

+     def get_reply_to_refchange(self, refchange):

+         return self.get_pusher_email()

+ 

+     def get_revision_recipients(self, revision):

+         """Return the recipients for messages about revision.

+ 

+         Return the list of email addresses to which notifications

+         about the specified Revision should be sent.  This method

+         could be overridden, for example, to take into account the

+         contents of the revision when deciding whom to notify about

+         it.  For example, there could be a scheme for users to express

+         interest in particular files or subdirectories, and only

+         receive notification emails for revisions that affecting those

+         files."""

+ 

+         raise NotImplementedError()

+ 

+     def get_reply_to_commit(self, revision):

+         return revision.author

+ 

+     def get_default_ref_ignore_regex(self):

+         # The commit messages of git notes are essentially meaningless

+         # and "filenames" in git notes commits are an implementational

+         # detail that might surprise users at first.  As such, we

+         # would need a completely different method for handling emails

+         # of git notes in order for them to be of benefit for users,

+         # which we simply do not have right now.

+         return "^refs/notes/"

+ 

+     def get_max_subject_length(self):

+         """Return the maximal subject line (git log --oneline) length.

+         Longer subject lines will be truncated."""

+         raise NotImplementedError()

+ 

+     def filter_body(self, lines):

+         """Filter the lines intended for an email body.

+ 

+         lines is an iterable over the lines that would go into the

+         email body.  Filter it (e.g., limit the number of lines, the

+         line length, character set, etc.), returning another iterable.

+         See FilterLinesEnvironmentMixin and MaxlinesEnvironmentMixin

+         for classes implementing this functionality."""

+ 

+         return lines

+ 

+     def log_msg(self, msg):

+         """Write the string msg on a log file or on stderr.

+ 

+         Sends the text to stderr by default, override to change the behavior."""

+         self.get_logger().info(msg)

+ 

+     def log_warning(self, msg):

+         """Write the string msg on a log file or on stderr.

  

-     if re.match(r'^refs/tags/.*$', refname):

-         if object_type == 'commit':

-             if change_type == CREATE:

-                 return make(LightweightTagCreation)

-             elif change_type == DELETE:

-                 return make(LightweightTagDeletion)

+         Sends the text to stderr by default, override to change the behavior."""

+         self.get_logger().warning(msg)

+ 

+     def log_error(self, msg):

+         """Write the string msg on a log file or on stderr.

+ 

+         Sends the text to stderr by default, override to change the behavior."""

+         self.get_logger().error(msg)

+ 

+     def check(self):

+         pass

+ 

+ 

+ class ConfigEnvironmentMixin(Environment):

+     """A mixin that sets self.config to its constructor's config argument.

+ 

+     This class's constructor consumes the "config" argument.

+ 

+     Mixins that need to inspect the config should inherit from this

+     class (1) to make sure that "config" is still in the constructor

+     arguments with its own constructor runs and/or (2) to be sure that

+     self.config is set after construction."""

+ 

+     def __init__(self, config, **kw):

+         super(ConfigEnvironmentMixin, self).__init__(**kw)

+         self.config = config

+ 

+ 

+ class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):

+     """An Environment that reads most of its information from "git config"."""

+ 

+     @staticmethod

+     def forbid_field_values(name, value, forbidden):

+         for forbidden_val in forbidden:

+             if value is not None and value.lower() == forbidden:

+                 raise ConfigurationException(

+                     '"%s" is not an allowed setting for %s' % (value, name)

+                 )

+ 

+     def __init__(self, config, **kw):

+         super(ConfigOptionsEnvironmentMixin, self).__init__(

+             config=config, **kw

+         )

+ 

+         for var, cfg in (

+             ("announce_show_shortlog", "announceshortlog"),

+             ("refchange_showgraph", "refchangeShowGraph"),

+             ("refchange_showlog", "refchangeshowlog"),

+             ("quiet", "quiet"),

+             ("stdout", "stdout"),

+         ):

+             val = config.get_bool(cfg)

+             if val is not None:

+                 setattr(self, var, val)

+ 

+         commit_email_format = config.get("commitEmailFormat")

+         if commit_email_format is not None:

+             if commit_email_format != "html" and commit_email_format != "text":

+                 self.log_warning(

+                     "*** Unknown value for multimailhook.commitEmailFormat: %s\n"

+                     % commit_email_format

+                     + '*** Expected either "text" or "html".  Ignoring.\n'

+                 )

              else:

-                 return make(LightweightTagUpdate)

-         elif object_type == 'tag':

-             if change_type == CREATE:

-                 return make(AnnotatedTagCreation)

-             elif change_type == DELETE:

-                 return make(AnnotatedTagDeletion)

+                 self.commit_email_format = commit_email_format

+ 

+         html_in_intro = config.get_bool("htmlInIntro")

+         if html_in_intro is not None:

+             self.html_in_intro = html_in_intro

+ 

+         html_in_footer = config.get_bool("htmlInFooter")

+         if html_in_footer is not None:

+             self.html_in_footer = html_in_footer

+ 

+         self.commitBrowseURL = config.get("commitBrowseURL")

+ 

+         maxcommitemails = config.get("maxcommitemails")

+         if maxcommitemails is not None:

+             try:

+                 self.maxcommitemails = int(maxcommitemails)

+             except ValueError:

+                 self.log_warning(

+                     "*** Malformed value for multimailhook.maxCommitEmails: %s\n"

+                     % maxcommitemails

+                     + "*** Expected a number.  Ignoring.\n"

+                 )

+ 

+         diffopts = config.get("diffopts")

+         if diffopts is not None:

+             self.diffopts = shlex.split(diffopts)

+ 

+         graphopts = config.get("graphOpts")

+         if graphopts is not None:

+             self.graphopts = shlex.split(graphopts)

+ 

+         logopts = config.get("logopts")

+         if logopts is not None:

+             self.logopts = shlex.split(logopts)

+ 

+         commitlogopts = config.get("commitlogopts")

+         if commitlogopts is not None:

+             self.commitlogopts = shlex.split(commitlogopts)

+ 

+         date_substitute = config.get("dateSubstitute")

+         if date_substitute == "none":

+             self.date_substitute = None

+         elif date_substitute is not None:

+             self.date_substitute = date_substitute

+ 

+         reply_to = config.get("replyTo")

+         self.__reply_to_refchange = config.get(

+             "replyToRefchange", default=reply_to

+         )

+         self.forbid_field_values(

+             "replyToRefchange", self.__reply_to_refchange, ["author"]

+         )

+         self.__reply_to_commit = config.get("replyToCommit", default=reply_to)

+ 

+         self.from_refchange = config.get("fromRefchange")

+         self.forbid_field_values(

+             "fromRefchange", self.from_refchange, ["author", "none"]

+         )

+         self.from_commit = config.get("fromCommit")

+         self.forbid_field_values("fromCommit", self.from_commit, ["none"])

+ 

+         combine = config.get_bool("combineWhenSingleCommit")

+         if combine is not None:

+             self.combine_when_single_commit = combine

+ 

+         self.log_file = config.get("logFile", default=None)

+         self.error_log_file = config.get("errorLogFile", default=None)

+         self.debug_log_file = config.get("debugLogFile", default=None)

+         if config.get_bool("Verbose", default=False):

+             self.verbose = 1

+         else:

+             self.verbose = 0

+ 

+     def get_administrator(self):

+         return (

+             self.config.get("administrator")

+             or self.get_sender()

+             or super(ConfigOptionsEnvironmentMixin, self).get_administrator()

+         )

+ 

+     def get_repo_shortname(self):

+         return (

+             self.config.get("reponame")

+             or super(ConfigOptionsEnvironmentMixin, self).get_repo_shortname()

+         )

+ 

+     def get_emailprefix(self):

+         emailprefix = self.config.get("emailprefix")

+         if emailprefix is not None:

+             emailprefix = emailprefix.strip()

+             if emailprefix:

+                 emailprefix += " "

+         else:

+             emailprefix = "[%(repo_shortname)s] "

+         short_name = self.get_repo_shortname()

+         try:

+             return emailprefix % {"repo_shortname": short_name}

+         except:

+             self.get_logger().error(

+                 "*** Invalid multimailhook.emailPrefix: %s\n" % emailprefix

+                 + "*** %s\n" % sys.exc_info()[1]

+                 + "*** Only the '%(repo_shortname)s' placeholder is allowed\n"

+             )

+             raise ConfigurationException(

+                 '"%s" is not an allowed setting for emailPrefix' % emailprefix

+             )

+ 

+     def get_sender(self):

+         return self.config.get("envelopesender")

+ 

+     def process_addr(self, addr, change):

+         if addr.lower() == "author":

+             if hasattr(change, "author"):

+                 return change.author

              else:

-                 return make(AnnotatedTagUpdate)

-         else:

-             return make_misc_change("%s is not a commit or tag object" % target)

-     elif re.match(r'^refs/heads/.*$', refname):

-         if object_type == 'commit':

-             if change_type == CREATE:

-                 return make(BranchCreation)

-             elif change_type == DELETE:

-                 return make(BranchDeletion)

+                 return None

+         elif addr.lower() == "pusher":

+             return self.get_pusher_email()

+         elif addr.lower() == "none":

+             return None

+         else:

+             return addr

+ 

+     def get_fromaddr(self, change=None):

+         fromaddr = self.config.get("from")

+         if change:

+             specific_fromaddr = change.get_specific_fromaddr()

+             if specific_fromaddr:

+                 fromaddr = specific_fromaddr

+         if fromaddr:

+             fromaddr = self.process_addr(fromaddr, change)

+         if fromaddr:

+             return fromaddr

+         return super(ConfigOptionsEnvironmentMixin, self).get_fromaddr(change)

+ 

+     def get_reply_to_refchange(self, refchange):

+         if self.__reply_to_refchange is None:

+             return super(

+                 ConfigOptionsEnvironmentMixin, self

+             ).get_reply_to_refchange(refchange)

+         else:

+             return self.process_addr(self.__reply_to_refchange, refchange)

+ 

+     def get_reply_to_commit(self, revision):

+         if self.__reply_to_commit is None:

+             return super(

+                 ConfigOptionsEnvironmentMixin, self

+             ).get_reply_to_commit(revision)

+         else:

+             return self.process_addr(self.__reply_to_commit, revision)

+ 

+     def get_scancommitforcc(self):

+         return self.config.get("scancommitforcc")

+ 

+ 

+ class FilterLinesEnvironmentMixin(Environment):

+     """Handle encoding and maximum line length of body lines.

+ 

+         email_max_line_length (int or None)

+ 

+             The maximum length of any single line in the email body.

+             Longer lines are truncated at that length with ' [...]'

+             appended.

+ 

+         strict_utf8 (bool)

+ 

+             If this field is set to True, then the email body text is

+             expected to be UTF-8.  Any invalid characters are

+             converted to U+FFFD, the Unicode replacement character

+             (encoded as UTF-8, of course).

+ 

+     """

+ 

+     def __init__(

+         self,

+         strict_utf8=True,

+         email_max_line_length=500,

+         max_subject_length=500,

+         **kw

+     ):

+         super(FilterLinesEnvironmentMixin, self).__init__(**kw)

+         self.__strict_utf8 = strict_utf8

+         self.__email_max_line_length = email_max_line_length

+         self.__max_subject_length = max_subject_length

+ 

+     def filter_body(self, lines):

+         lines = super(FilterLinesEnvironmentMixin, self).filter_body(lines)

+         if self.__strict_utf8:

+             if not PYTHON3:

+                 lines = (line.decode(ENCODING, "replace") for line in lines)

+             # Limit the line length in Unicode-space to avoid

+             # splitting characters:

+             if self.__email_max_line_length > 0:

+                 lines = limit_linelength(lines, self.__email_max_line_length)

+             if not PYTHON3:

+                 lines = (line.encode(ENCODING, "replace") for line in lines)

+         elif self.__email_max_line_length:

+             lines = limit_linelength(lines, self.__email_max_line_length)

+ 

+         return lines

+ 

+     def get_max_subject_length(self):

+         return self.__max_subject_length

+ 

+ 

+ class ConfigFilterLinesEnvironmentMixin(

+     ConfigEnvironmentMixin, FilterLinesEnvironmentMixin

+ ):

+     """Handle encoding and maximum line length based on config."""

+ 

+     def __init__(self, config, **kw):

+         strict_utf8 = config.get_bool("emailstrictutf8", default=None)

+         if strict_utf8 is not None:

+             kw["strict_utf8"] = strict_utf8

+ 

+         email_max_line_length = config.get("emailmaxlinelength")

+         if email_max_line_length is not None:

+             kw["email_max_line_length"] = int(email_max_line_length)

+ 

+         max_subject_length = config.get(

+             "subjectMaxLength", default=email_max_line_length

+         )

+         if max_subject_length is not None:

+             kw["max_subject_length"] = int(max_subject_length)

+ 

+         super(ConfigFilterLinesEnvironmentMixin, self).__init__(

+             config=config, **kw

+         )

+ 

+ 

+ class MaxlinesEnvironmentMixin(Environment):

+     """Limit the email body to a specified number of lines."""

+ 

+     def __init__(self, emailmaxlines, **kw):

+         super(MaxlinesEnvironmentMixin, self).__init__(**kw)

+         self.__emailmaxlines = emailmaxlines

+ 

+     def filter_body(self, lines):

+         lines = super(MaxlinesEnvironmentMixin, self).filter_body(lines)

+         if self.__emailmaxlines > 0:

+             lines = limit_lines(lines, self.__emailmaxlines)

+         return lines

+ 

+ 

+ class ConfigMaxlinesEnvironmentMixin(

+     ConfigEnvironmentMixin, MaxlinesEnvironmentMixin

+ ):

+     """Limit the email body to the number of lines specified in config."""

+ 

+     def __init__(self, config, **kw):

+         emailmaxlines = int(config.get("emailmaxlines", default="0"))

+         super(ConfigMaxlinesEnvironmentMixin, self).__init__(

+             config=config, emailmaxlines=emailmaxlines, **kw

+         )

+ 

+ 

+ class FQDNEnvironmentMixin(Environment):

+     """A mixin that sets the host's FQDN to its constructor argument."""

+ 

+     def __init__(self, fqdn, **kw):

+         super(FQDNEnvironmentMixin, self).__init__(**kw)

+         self.COMPUTED_KEYS += ["fqdn"]

+         self.__fqdn = fqdn

+ 

+     def get_fqdn(self):

+         """Return the fully-qualified domain name for this host.

+ 

+         Return None if it is unavailable or unwanted."""

+ 

+         return self.__fqdn

+ 

+ 

+ class ConfigFQDNEnvironmentMixin(ConfigEnvironmentMixin, FQDNEnvironmentMixin):

+     """Read the FQDN from the config."""

+ 

+     def __init__(self, config, **kw):

+         fqdn = config.get("fqdn")

+         super(ConfigFQDNEnvironmentMixin, self).__init__(

+             config=config, fqdn=fqdn, **kw

+         )

+ 

+ 

+ class ComputeFQDNEnvironmentMixin(FQDNEnvironmentMixin):

+     """Get the FQDN by calling socket.getfqdn()."""

+ 

+     def __init__(self, **kw):

+         super(ComputeFQDNEnvironmentMixin, self).__init__(

+             fqdn=socket.getfqdn(), **kw

+         )

+ 

+ 

+ class PusherDomainEnvironmentMixin(ConfigEnvironmentMixin):

+     """Deduce pusher_email from pusher by appending an emaildomain."""

+ 

+     def __init__(self, **kw):

+         super(PusherDomainEnvironmentMixin, self).__init__(**kw)

+         self.__emaildomain = self.config.get("emaildomain")

+ 

+     def get_pusher_email(self):

+         if self.__emaildomain:

+             # Derive the pusher's full email address in the default way:

+             return "%s@%s" % (self.get_pusher(), self.__emaildomain)

+         else:

+             return super(PusherDomainEnvironmentMixin, self).get_pusher_email()

+ 

+ 

+ class StaticRecipientsEnvironmentMixin(Environment):

+     """Set recipients statically based on constructor parameters."""

+ 

+     def __init__(

+         self,

+         refchange_recipients,

+         announce_recipients,

+         revision_recipients,

+         scancommitforcc,

+         **kw

+     ):

+         super(StaticRecipientsEnvironmentMixin, self).__init__(**kw)

+ 

+         # The recipients for various types of notification emails, as

+         # RFC 2822 email addresses separated by commas (or the empty

+         # string if no recipients are configured).  Although there is

+         # a mechanism to choose the recipient lists based on on the

+         # actual *contents* of the change being reported, we only

+         # choose based on the *type* of the change.  Therefore we can

+         # compute them once and for all:

+         self.__refchange_recipients = refchange_recipients

+         self.__announce_recipients = announce_recipients

+         self.__revision_recipients = revision_recipients

+ 

+     def check(self):

+         if not (

+             self.get_refchange_recipients(None)

+             or self.get_announce_recipients(None)

+             or self.get_revision_recipients(None)

+             or self.get_scancommitforcc()

+         ):

+             raise ConfigurationException("No email recipients configured!")

+         super(StaticRecipientsEnvironmentMixin, self).check()

+ 

+     def get_refchange_recipients(self, refchange):

+         if self.__refchange_recipients is None:

+             return super(

+                 StaticRecipientsEnvironmentMixin, self

+             ).get_refchange_recipients(refchange)

+         return self.__refchange_recipients

+ 

+     def get_announce_recipients(self, annotated_tag_change):

+         if self.__announce_recipients is None:

+             return super(

+                 StaticRecipientsEnvironmentMixin, self

+             ).get_refchange_recipients(annotated_tag_change)

+         return self.__announce_recipients

+ 

+     def get_revision_recipients(self, revision):

+         if self.__revision_recipients is None:

+             return super(

+                 StaticRecipientsEnvironmentMixin, self

+             ).get_refchange_recipients(revision)

+         return self.__revision_recipients

+ 

+ 

+ class CLIRecipientsEnvironmentMixin(Environment):

+     """Mixin storing recipients information comming from the

+     command-line."""

+ 

+     def __init__(self, cli_recipients=None, **kw):

+         super(CLIRecipientsEnvironmentMixin, self).__init__(**kw)

+         self.__cli_recipients = cli_recipients

+ 

+     def get_refchange_recipients(self, refchange):

+         if self.__cli_recipients is None:

+             return super(

+                 CLIRecipientsEnvironmentMixin, self

+             ).get_refchange_recipients(refchange)

+         return self.__cli_recipients

+ 

+     def get_announce_recipients(self, annotated_tag_change):

+         if self.__cli_recipients is None:

+             return super(

+                 CLIRecipientsEnvironmentMixin, self

+             ).get_announce_recipients(annotated_tag_change)

+         return self.__cli_recipients

+ 

+     def get_revision_recipients(self, revision):

+         if self.__cli_recipients is None:

+             return super(

+                 CLIRecipientsEnvironmentMixin, self

+             ).get_revision_recipients(revision)

+         return self.__cli_recipients

+ 

+ 

+ class ConfigRecipientsEnvironmentMixin(

+     ConfigEnvironmentMixin, StaticRecipientsEnvironmentMixin

+ ):

+     """Determine recipients statically based on config."""

+ 

+     def __init__(self, config, **kw):

+         super(ConfigRecipientsEnvironmentMixin, self).__init__(

+             config=config,

+             refchange_recipients=self._get_recipients(

+                 config, "refchangelist", "mailinglist"

+             ),

+             announce_recipients=self._get_recipients(

+                 config, "announcelist", "refchangelist", "mailinglist"

+             ),

+             revision_recipients=self._get_recipients(

+                 config, "commitlist", "mailinglist"

+             ),

+             scancommitforcc=config.get("scancommitforcc"),

+             **kw

+         )

+ 

+     def _get_recipients(self, config, *names):

+         """Return the recipients for a particular type of message.

+ 

+         Return the list of email addresses to which a particular type

+         of notification email should be sent, by looking at the config

+         value for "multimailhook.$name" for each of names.  Use the

+         value from the first name that is configured.  The return

+         value is a (possibly empty) string containing RFC 2822 email

+         addresses separated by commas.  If no configuration could be

+         found, raise a ConfigurationException."""

+ 

+         for name in names:

+             lines = config.get_all(name)

+             if lines is not None:

+                 lines = [line.strip() for line in lines]

+                 # Single "none" is a special value equivalen to empty string.

+                 if lines == ["none"]:

+                     lines = [""]

+                 return ", ".join(lines)

+         else:

+             return ""

+ 

+ 

+ class StaticRefFilterEnvironmentMixin(Environment):

+     """Set branch filter statically based on constructor parameters."""

+ 

+     def __init__(

+         self,

+         ref_filter_incl_regex,

+         ref_filter_excl_regex,

+         ref_filter_do_send_regex,

+         ref_filter_dont_send_regex,

+         **kw

+     ):

+         super(StaticRefFilterEnvironmentMixin, self).__init__(**kw)

+ 

+         if ref_filter_incl_regex and ref_filter_excl_regex:

+             raise ConfigurationException(

+                 "Cannot specify both a ref inclusion and exclusion regex."

+             )

+         self.__is_inclusion_filter = bool(ref_filter_incl_regex)

+         default_exclude = self.get_default_ref_ignore_regex()

+         if ref_filter_incl_regex:

+             ref_filter_regex = ref_filter_incl_regex

+         elif ref_filter_excl_regex:

+             ref_filter_regex = ref_filter_excl_regex + "|" + default_exclude

+         else:

+             ref_filter_regex = default_exclude

+         try:

+             self.__compiled_regex = re.compile(ref_filter_regex)

+         except Exception:

+             raise ConfigurationException(

+                 'Invalid Ref Filter Regex "%s": %s'

+                 % (ref_filter_regex, sys.exc_info()[1])

+             )

+ 

+         if ref_filter_do_send_regex and ref_filter_dont_send_regex:

+             raise ConfigurationException(

+                 "Cannot specify both a ref doSend and dontSend regex."

+             )

+         self.__is_do_send_filter = bool(ref_filter_do_send_regex)

+         if ref_filter_do_send_regex:

+             ref_filter_send_regex = ref_filter_do_send_regex

+         elif ref_filter_dont_send_regex:

+             ref_filter_send_regex = ref_filter_dont_send_regex

+         else:

+             ref_filter_send_regex = ".*"

+             self.__is_do_send_filter = True

+         try:

+             self.__send_compiled_regex = re.compile(ref_filter_send_regex)

+         except Exception:

+             raise ConfigurationException(

+                 'Invalid Ref Filter Regex "%s": %s'

+                 % (ref_filter_send_regex, sys.exc_info()[1])

+             )

+ 

+     def get_ref_filter_regex(self, send_filter=False):

+         if send_filter:

+             return self.__send_compiled_regex, self.__is_do_send_filter

+         else:

+             return self.__compiled_regex, self.__is_inclusion_filter

+ 

+ 

+ class ConfigRefFilterEnvironmentMixin(

+     ConfigEnvironmentMixin, StaticRefFilterEnvironmentMixin

+ ):

+     """Determine branch filtering statically based on config."""

+ 

+     def _get_regex(self, config, key):

+         """Get a list of whitespace-separated regex. The refFilter* config

+         variables are multivalued (hence the use of get_all), and we

+         allow each entry to be a whitespace-separated list (hence the

+         split on each line). The whole thing is glued into a single regex."""

+         values = config.get_all(key)

+         if values is None:

+             return values

+         items = []

+         for line in values:

+             for i in line.split():

+                 items.append(i)

+         if items == []:

+             return None

+         return "|".join(items)

+ 

+     def __init__(self, config, **kw):

+         super(ConfigRefFilterEnvironmentMixin, self).__init__(

+             config=config,

+             ref_filter_incl_regex=self._get_regex(

+                 config, "refFilterInclusionRegex"

+             ),

+             ref_filter_excl_regex=self._get_regex(

+                 config, "refFilterExclusionRegex"

+             ),

+             ref_filter_do_send_regex=self._get_regex(

+                 config, "refFilterDoSendRegex"

+             ),

+             ref_filter_dont_send_regex=self._get_regex(

+                 config, "refFilterDontSendRegex"

+             ),

+             **kw

+         )

+ 

+ 

+ class ProjectdescEnvironmentMixin(Environment):

+     """Make a "projectdesc" value available for templates.

+ 

+     By default, it is set to the first line of $GIT_DIR/description

+     (if that file is present and appears to be set meaningfully)."""

+ 

+     def __init__(self, **kw):

+         super(ProjectdescEnvironmentMixin, self).__init__(**kw)

+         self.COMPUTED_KEYS += ["projectdesc"]

+ 

+     def get_projectdesc(self):

+         """Return a one-line descripition of the project."""

+ 

+         git_dir = get_git_dir()

+         try:

+             projectdesc = (

+                 open(os.path.join(git_dir, "description")).readline().strip()

+             )

+             if projectdesc and not projectdesc.startswith(

+                 "Unnamed repository"

+             ):

+                 return projectdesc

+         except IOError:

+             pass

+ 

+         return "UNNAMED PROJECT"

+ 

+ 

+ class GenericEnvironmentMixin(Environment):

+     def get_pusher(self):

+         return self.osenv.get(

+             "USER", self.osenv.get("USERNAME", "unknown user")

+         )

+ 

+ 

+ class GitoliteEnvironmentHighPrecMixin(Environment):

+     def get_pusher(self):

+         return self.osenv.get("GL_USER", "unknown user")

+ 

+ 

+ class GitoliteEnvironmentLowPrecMixin(Environment):

+     def get_repo_shortname(self):

+         # The gitolite environment variable $GL_REPO is a pretty good

+         # repo_shortname (though it's probably not as good as a value

+         # the user might have explicitly put in his config).

+         return (

+             self.osenv.get("GL_REPO", None)

+             or super(

+                 GitoliteEnvironmentLowPrecMixin, self

+             ).get_repo_shortname()

+         )

+ 

+     def get_fromaddr(self, change=None):

+         GL_USER = self.osenv.get("GL_USER")

+         if GL_USER is not None:

+             # Find the path to gitolite.conf.  Note that gitolite v3

+             # did away with the GL_ADMINDIR and GL_CONF environment

+             # variables (they are now hard-coded).

+             GL_ADMINDIR = self.osenv.get(

+                 "GL_ADMINDIR",

+                 os.path.expanduser(os.path.join("~", ".gitolite")),

+             )

+             GL_CONF = self.osenv.get(

+                 "GL_CONF", os.path.join(GL_ADMINDIR, "conf", "gitolite.conf")

+             )

+             if os.path.isfile(GL_CONF):

+                 f = open(GL_CONF, "rU")

+                 try:

+                     in_user_emails_section = False

+                     re_template = r"^\s*#\s*%s\s*$"

+                     re_begin, re_user, re_end = (

+                         re.compile(re_template % x)

+                         for x in (

+                             r"BEGIN\s+USER\s+EMAILS",

+                             re.escape(GL_USER) + r"\s+(.*)",

+                             r"END\s+USER\s+EMAILS",

+                         )

+                     )

+                     for l in f:

+                         l = l.rstrip("\n")

+                         if not in_user_emails_section:

+                             if re_begin.match(l):

+                                 in_user_emails_section = True

+                             continue

+                         if re_end.match(l):

+                             break

+                         m = re_user.match(l)

+                         if m:

+                             return m.group(1)

+                 finally:

+                     f.close()

+         return super(GitoliteEnvironmentLowPrecMixin, self).get_fromaddr(

+             change

+         )

+ 

+ 

+ class IncrementalDateTime(object):

+     """Simple wrapper to give incremental date/times.

+ 

+     Each call will result in a date/time a second later than the

+     previous call.  This can be used to falsify email headers, to

+     increase the likelihood that email clients sort the emails

+     correctly."""

+ 

+     def __init__(self):

+         self.time = time.time()

+         self.next = self.__next__  # Python 2 backward compatibility

+ 

+     def __next__(self):

+         formatted = formatdate(self.time, True)

+         self.time += 1

+         return formatted

+ 

+ 

+ class StashEnvironmentHighPrecMixin(Environment):

+     def __init__(self, user=None, repo=None, **kw):

+         super(StashEnvironmentHighPrecMixin, self).__init__(

+             user=user, repo=repo, **kw

+         )

+         self.__user = user

+         self.__repo = repo

+ 

+     def get_pusher(self):

+         return re.match("(.*?)\s*<", self.__user).group(1)

+ 

+     def get_pusher_email(self):

+         return self.__user

+ 

+ 

+ class StashEnvironmentLowPrecMixin(Environment):

+     def __init__(self, user=None, repo=None, **kw):

+         super(StashEnvironmentLowPrecMixin, self).__init__(**kw)

+         self.__repo = repo

+         self.__user = user

+ 

+     def get_repo_shortname(self):

+         return self.__repo

+ 

+     def get_fromaddr(self, change=None):

+         return self.__user

+ 

+ 

+ class GerritEnvironmentHighPrecMixin(Environment):

+     def __init__(self, project=None, submitter=None, update_method=None, **kw):

+         super(GerritEnvironmentHighPrecMixin, self).__init__(

+             submitter=submitter, project=project, **kw

+         )

+         self.__project = project

+         self.__submitter = submitter

+         self.__update_method = update_method

+         "Make an 'update_method' value available for templates."

+         self.COMPUTED_KEYS += ["update_method"]

+ 

+     def get_pusher(self):

+         if self.__submitter:

+             if self.__submitter.find("<") != -1:

+                 # Submitter has a configured email, we transformed

+                 # __submitter into an RFC 2822 string already.

+                 return re.match("(.*?)\s*<", self.__submitter).group(1)

              else:

-                 return make(BranchUpdate)

+                 # Submitter has no configured email, it's just his name.

+                 return self.__submitter

          else:

-             return make_misc_change("%s is not a commit object" % target)

-     elif re.match(r'^refs/remotes/.*$', refname):

-         return make_misc_change("'%s' is a tracking branch and doesn't belong on the server" % refname)

-     else:

-         return make_misc_change("'%s' is not in refs/heads/ or refs/tags/" % refname)

- 

- def main():

-     global projectshort

-     global projectdesc

-     global user_fullname

-     global recipients

-     global maildomain

-     global mailshortdiff

- 

-     # No emails for a repository in the process of being imported

-     git_dir = git.rev_parse(git_dir=True, _quiet=True)

-     if os.path.exists(os.path.join(git_dir, 'pending')):

-         return

-     

-     projectshort = get_module_name()

-     projectdesc = get_project_description()

+             # If we arrive here, this means someone pushed "Submit" from

+             # the gerrit web UI for the CR (or used one of the programmatic

+             # APIs to do the same, such as gerrit review) and the

+             # merge/push was done by the Gerrit user.  It was technically

+             # triggered by someone else, but sadly we have no way of

+             # determining who that someone else is at this point.

+             return "Gerrit"  # 'unknown user'?

+ 

+     def get_pusher_email(self):

+         if self.__submitter:

+             return self.__submitter

+         else:

+             return super(

+                 GerritEnvironmentHighPrecMixin, self

+             ).get_pusher_email()

+ 

+     def get_default_ref_ignore_regex(self):

+         default = super(

+             GerritEnvironmentHighPrecMixin, self

+         ).get_default_ref_ignore_regex()

+         return default + "|^refs/changes/|^refs/cache-automerge/|^refs/meta/"

+ 

+     def get_revision_recipients(self, revision):

+         # Merge commits created by Gerrit when users hit "Submit this patchset"

+         # in the Web UI (or do equivalently with REST APIs or the gerrit review

+         # command) are not something users want to see an individual email for.

+         # Filter them out.

+         committer = read_git_output(

+             ["log", "--no-walk", "--format=%cN", revision.rev.sha1]

+         )

+         if committer == "Gerrit Code Review":

+             return []

+         else:

+             return super(

+                 GerritEnvironmentHighPrecMixin, self

+             ).get_revision_recipients(revision)

  

+     def get_update_method(self):

+         return self.__update_method

  

-     try:

-         mailshortdiff=git.config("hooks.mailshortdiff", _quiet=True)

-     except CalledProcessError:

-         pass

-     

-     if isinstance(mailshortdiff, str) and mailshortdiff.lower() in ('true', 'yes', 'on', '1'):

-         mailshortdiff = True

-     else:

-         mailshortdiff = False

  

-     try:

-         recipients=git.config("hooks.mailinglist", _quiet=True)

-     except CalledProcessError:

-         pass

-         

-     if not recipients:

-         die("hooks.mailinglist is not set")

+ class GerritEnvironmentLowPrecMixin(Environment):

+     def __init__(self, project=None, submitter=None, **kw):

+         super(GerritEnvironmentLowPrecMixin, self).__init__(**kw)

+         self.__project = project

+         self.__submitter = submitter

  

-     # Get the domain name to use in the From header

-     try:

-         maildomain = git.config("hooks.maildomain", _quiet=True)

-     except CalledProcessError:

-         pass

+     def get_repo_shortname(self):

+         return self.__project

  

-     if not maildomain:

+     def get_fromaddr(self, change=None):

+         if self.__submitter and self.__submitter.find("<") != -1:

+             return self.__submitter

+         else:

+             return super(GerritEnvironmentLowPrecMixin, self).get_fromaddr(

+                 change

+             )

+ 

+ 

+ class Push(object):

+     """Represent an entire push (i.e., a group of ReferenceChanges).

+ 

+     It is easy to figure out what commits were added to a *branch* by

+     a Reference change:

+ 

+         git rev-list change.old..change.new

+ 

+     or removed from a *branch*:

+ 

+         git rev-list change.new..change.old

+ 

+     But it is not quite so trivial to determine which entirely new

+     commits were added to the *repository* by a push and which old

+     commits were discarded by a push.  A big part of the job of this

+     class is to figure out these things, and to make sure that new

+     commits are only detailed once even if they were added to multiple

+     references.

+ 

+     The first step is to determine the "other" references--those

+     unaffected by the current push.  They are computed by listing all

+     references then removing any affected by this push.  The results

+     are stored in Push._other_ref_sha1s.

+ 

+     The commits contained in the repository before this push were

+ 

+         git rev-list other1 other2 other3 ... change1.old change2.old ...

+ 

+     Where "changeN.old" is the old value of one of the references

+     affected by this push.

+ 

+     The commits contained in the repository after this push are

+ 

+         git rev-list other1 other2 other3 ... change1.new change2.new ...

+ 

+     The commits added by this push are the difference between these

+     two sets, which can be written

+ 

+         git rev-list \

+             ^other1 ^other2 ... \

+             ^change1.old ^change2.old ... \

+             change1.new change2.new ...

+ 

+     The commits removed by this push can be computed by

+ 

+         git rev-list \

+             ^other1 ^other2 ... \

+             ^change1.new ^change2.new ... \

+             change1.old change2.old ...

+ 

+     The last point is that it is possible that other pushes are

+     occurring simultaneously to this one, so reference values can

+     change at any time.  It is impossible to eliminate all race

+     conditions, but we reduce the window of time during which problems

+     can occur by translating reference names to SHA1s as soon as

+     possible and working with SHA1s thereafter (because SHA1s are

+     immutable)."""

+ 

+     # A map {(changeclass, changetype): integer} specifying the order

+     # that reference changes will be processed if multiple reference

+     # changes are included in a single push.  The order is significant

+     # mostly because new commit notifications are threaded together

+     # with the first reference change that includes the commit.  The

+     # following order thus causes commits to be grouped with branch

+     # changes (as opposed to tag changes) if possible.

+     SORT_ORDER = dict(

+         (value, i)

+         for (i, value) in enumerate(

+             [

+                 (BranchChange, "update"),

+                 (BranchChange, "create"),

+                 (AnnotatedTagChange, "update"),

+                 (AnnotatedTagChange, "create"),

+                 (NonAnnotatedTagChange, "update"),

+                 (NonAnnotatedTagChange, "create"),

+                 (BranchChange, "delete"),

+                 (AnnotatedTagChange, "delete"),

+                 (NonAnnotatedTagChange, "delete"),

+                 (OtherReferenceChange, "update"),

+                 (OtherReferenceChange, "create"),

+                 (OtherReferenceChange, "delete"),

+             ]

+         )

+     )

+ 

+     def __init__(self, environment, changes, ignore_other_refs=False):

+         self.changes = sorted(changes, key=self._sort_key)

+         self.__other_ref_sha1s = None

+         self.__cached_commits_spec = {}

+         self.environment = environment

+ 

+         if ignore_other_refs:

+             self.__other_ref_sha1s = set()

+ 

+     @classmethod

+     def _sort_key(klass, change):

+         return (

+             klass.SORT_ORDER[change.__class__, change.change_type],

+             change.refname,

+         )

+ 

+     @property

+     def _other_ref_sha1s(self):

+         """The GitObjects referred to by references unaffected by this push.

+         """

+         if self.__other_ref_sha1s is None:

+             # The refnames being changed by this push:

+             updated_refs = set(change.refname for change in self.changes)

+ 

+             # The SHA-1s of commits referred to by all references in this

+             # repository *except* updated_refs:

+             sha1s = set()

+             fmt = (

+                 "%(objectname) %(objecttype) %(refname)\n"

+                 "%(*objectname) %(*objecttype) %(refname)"

+             )

+             (

+                 ref_filter_regex,

+                 is_inclusion_filter,

+             ) = self.environment.get_ref_filter_regex()

+             for line in read_git_lines(

+                 ["for-each-ref", "--format=%s" % (fmt,)]

+             ):

+                 (sha1, type, name) = line.split(" ", 2)

+                 if (

+                     sha1

+                     and type == "commit"

+                     and name not in updated_refs

+                     and include_ref(

+                         name, ref_filter_regex, is_inclusion_filter

+                     )

+                 ):

+                     sha1s.add(sha1)

+ 

+             self.__other_ref_sha1s = sha1s

+ 

+         return self.__other_ref_sha1s

+ 

+     def _get_commits_spec_incl(self, new_or_old, reference_change=None):

+         """Get new or old SHA-1 from one or each of the changed refs.

+ 

+         Return a list of SHA-1 commit identifier strings suitable as

+         arguments to 'git rev-list' (or 'git log' or ...).  The

+         returned identifiers are either the old or new values from one

+         or all of the changed references, depending on the values of

+         new_or_old and reference_change.

+ 

+         new_or_old is either the string 'new' or the string 'old'.  If

+         'new', the returned SHA-1 identifiers are the new values from

+         each changed reference.  If 'old', the SHA-1 identifiers are

+         the old values from each changed reference.

+ 

+         If reference_change is specified and not None, only the new or

+         old reference from the specified reference is included in the

+         return value.

+ 

+         This function returns None if there are no matching revisions

+         (e.g., because a branch was deleted and new_or_old is 'new').

+         """

+ 

+         if not reference_change:

+             incl_spec = sorted(

+                 getattr(change, new_or_old).sha1

+                 for change in self.changes

+                 if getattr(change, new_or_old)

+             )

+             if not incl_spec:

+                 incl_spec = None

+         elif not getattr(reference_change, new_or_old).commit_sha1:

+             incl_spec = None

+         else:

+             incl_spec = [getattr(reference_change, new_or_old).commit_sha1]

+         return incl_spec

+ 

+     def _get_commits_spec_excl(self, new_or_old):

+         """Get exclusion revisions for determining new or discarded commits.

+ 

+         Return a list of strings suitable as arguments to 'git

+         rev-list' (or 'git log' or ...) that will exclude all

+         commits that, depending on the value of new_or_old, were

+         either previously in the repository (useful for determining

+         which commits are new to the repository) or currently in the

+         repository (useful for determining which commits were

+         discarded from the repository).

+ 

+         new_or_old is either the string 'new' or the string 'old'.  If

+         'new', the commits to be excluded are those that were in the

+         repository before the push.  If 'old', the commits to be

+         excluded are those that are currently in the repository.  """

+ 

+         old_or_new = {"old": "new", "new": "old"}[new_or_old]

+         excl_revs = self._other_ref_sha1s.union(

+             getattr(change, old_or_new).sha1

+             for change in self.changes

+             if getattr(change, old_or_new).type in ["commit", "tag"]

+         )

+         return ["^" + sha1 for sha1 in sorted(excl_revs)]

+ 

+     def get_commits_spec(self, new_or_old, reference_change=None):

+         """Get rev-list arguments for added or discarded commits.

+ 

+         Return a list of strings suitable as arguments to 'git

+         rev-list' (or 'git log' or ...) that select those commits

+         that, depending on the value of new_or_old, are either new to

+         the repository or were discarded from the repository.

+ 

+         new_or_old is either the string 'new' or the string 'old'.  If

+         'new', the returned list is used to select commits that are

+         new to the repository.  If 'old', the returned value is used

+         to select the commits that have been discarded from the

+         repository.

+ 

+         If reference_change is specified and not None, the new or

+         discarded commits are limited to those that are reachable from

+         the new or old value of the specified reference.

+ 

+         This function returns None if there are no added (or discarded)

+         revisions.

+         """

+         key = (new_or_old, reference_change)

+         if key not in self.__cached_commits_spec:

+             ret = self._get_commits_spec_incl(new_or_old, reference_change)

+             if ret is not None:

+                 ret.extend(self._get_commits_spec_excl(new_or_old))

+             self.__cached_commits_spec[key] = ret

+         return self.__cached_commits_spec[key]

+ 

+     def get_new_commits(self, reference_change=None):

+         """Return a list of commits added by this push.

+ 

+         Return a list of the object names of commits that were added

+         by the part of this push represented by reference_change.  If

+         reference_change is None, then return a list of *all* commits

+         added by this push."""

+ 

+         spec = self.get_commits_spec("new", reference_change)

+         return git_rev_list(spec)

+ 

+     def get_discarded_commits(self, reference_change):

+         """Return a list of commits discarded by this push.

+ 

+         Return a list of the object names of commits that were

+         entirely discarded from the repository by the part of this

+         push represented by reference_change."""

+ 

+         spec = self.get_commits_spec("old", reference_change)

+         return git_rev_list(spec)

+ 

+     def send_emails(self, mailer, body_filter=None):

+         """Use send all of the notification emails needed for this push.

+ 

+         Use send all of the notification emails (including reference

+         change emails and commit emails) needed for this push.  Send

+         the emails using mailer.  If body_filter is not None, then use

+         it to filter the lines that are intended for the email

+         body."""

+ 

+         # The sha1s of commits that were introduced by this push.

+         # They will be removed from this set as they are processed, to

+         # guarantee that one (and only one) email is generated for

+         # each new commit.

+         unhandled_sha1s = set(self.get_new_commits())

+         send_date = IncrementalDateTime()

+         for change in self.changes:

+             sha1s = []

+             for sha1 in reversed(list(self.get_new_commits(change))):

+                 if sha1 in unhandled_sha1s:

+                     sha1s.append(sha1)

+                     unhandled_sha1s.remove(sha1)

+ 

+             # Check if we've got anyone to send to

+             if not change.recipients:

+                 change.environment.log_warning(

+                     "*** no recipients configured so no email will be sent\n"

+                     "*** for %r update %s->%s"

+                     % (change.refname, change.old.sha1, change.new.sha1)

+                 )

+             else:

+                 if not change.environment.quiet:

+                     change.environment.log_msg(

+                         "Sending notification emails to: %s"

+                         % (change.recipients,)

+                     )

+                 extra_values = {"send_date": next(send_date)}

+ 

+                 rev = change.send_single_combined_email(sha1s)

+                 if rev:

+                     mailer.send(

+                         change.generate_combined_email(

+                             self, rev, body_filter, extra_values

+                         ),

+                         rev.recipients,

+                     )

+                     # This change is now fully handled; no need to handle

+                     # individual revisions any further.

+                     continue

+                 else:

+                     mailer.send(

+                         change.generate_email(self, body_filter, extra_values),

+                         change.recipients,

+                     )

+ 

+             max_emails = change.environment.maxcommitemails

+             if max_emails and len(sha1s) > max_emails:

+                 change.environment.log_warning(

+                     "*** Too many new commits (%d), not sending commit emails.\n"

+                     % len(sha1s)

+                     + "*** Try setting multimailhook.maxCommitEmails to a greater value\n"

+                     + "*** Currently, multimailhook.maxCommitEmails=%d"

+                     % max_emails

+                 )

+                 return

+ 

+             for (num, sha1) in enumerate(sha1s):

+                 rev = Revision(

+                     change, GitObject(sha1), num=num + 1, tot=len(sha1s)

+                 )

+                 if not rev.recipients and rev.cc_recipients:

+                     change.environment.log_msg("*** Replacing Cc: with To:")

+                     rev.recipients = rev.cc_recipients

+                     rev.cc_recipients = None

+                 if rev.recipients:

+                     extra_values = {"send_date": next(send_date)}

+                     mailer.send(

+                         rev.generate_email(self, body_filter, extra_values),

+                         rev.recipients,

+                     )

+ 

+         # Consistency check:

+         if unhandled_sha1s:

+             change.environment.log_error(

+                 "ERROR: No emails were sent for the following new commits:\n"

+                 "    %s" % ("\n    ".join(sorted(unhandled_sha1s)),)

+             )

+ 

+ 

+ def include_ref(refname, ref_filter_regex, is_inclusion_filter):

+     does_match = bool(ref_filter_regex.search(refname))

+     if is_inclusion_filter:

+         return does_match

+     else:  # exclusion filter -- we include the ref if the regex doesn't match

+         return not does_match

+ 

+ 

+ def run_as_post_receive_hook(environment, mailer):

+     environment.check()

+     (

+         send_filter_regex,

+         send_is_inclusion_filter,

+     ) = environment.get_ref_filter_regex(True)

+     ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(

+         False

+     )

+     changes = []

+     while True:

+         line = read_line(sys.stdin)

+         if line == "":

+             break

+         (oldrev, newrev, refname) = line.strip().split(" ", 2)

+         environment.get_logger().debug(

+             "run_as_post_receive_hook: oldrev=%s, newrev=%s, refname=%s"

+             % (oldrev, newrev, refname)

+         )

+ 

+         if not include_ref(refname, ref_filter_regex, is_inclusion_filter):

+             continue

+         if not include_ref(

+             refname, send_filter_regex, send_is_inclusion_filter

+         ):

+             continue

+         changes.append(

+             ReferenceChange.create(environment, oldrev, newrev, refname)

+         )

+     if changes:

+         push = Push(environment, changes)

+         push.send_emails(mailer, body_filter=environment.filter_body)

+     if hasattr(mailer, "__del__"):

+         mailer.__del__()

+ 

+ 

+ def run_as_update_hook(

+     environment, mailer, refname, oldrev, newrev, force_send=False

+ ):

+     environment.check()

+     (

+         send_filter_regex,

+         send_is_inclusion_filter,

+     ) = environment.get_ref_filter_regex(True)

+     ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(

+         False

+     )

+     if not include_ref(refname, ref_filter_regex, is_inclusion_filter):

+         return

+     if not include_ref(refname, send_filter_regex, send_is_inclusion_filter):

+         return

+     changes = [

+         ReferenceChange.create(

+             environment,

+             read_git_output(["rev-parse", "--verify", oldrev]),

+             read_git_output(["rev-parse", "--verify", newrev]),

+             refname,

+         )

+     ]

+     push = Push(environment, changes, force_send)

+     push.send_emails(mailer, body_filter=environment.filter_body)

+     if hasattr(mailer, "__del__"):

+         mailer.__del__()

+ 

+ 

+ def check_ref_filter(environment):

+     send_filter_regex, send_is_inclusion = environment.get_ref_filter_regex(

+         True

+     )

+     ref_filter_regex, ref_is_inclusion = environment.get_ref_filter_regex(

+         False

+     )

+ 

+     def inc_exc_lusion(b):

+         if b:

+             return "inclusion"

+         else:

+             return "exclusion"

+ 

+     if send_filter_regex:

+         sys.stdout.write(

+             "DoSend/DontSend filter regex ("

+             + (inc_exc_lusion(send_is_inclusion))

+             + "): "

+             + send_filter_regex.pattern

+             + "\n"

+         )

+     if send_filter_regex:

+         sys.stdout.write(

+             "Include/Exclude filter regex ("

+             + (inc_exc_lusion(ref_is_inclusion))

+             + "): "

+             + ref_filter_regex.pattern

+             + "\n"

+         )

+     sys.stdout.write(os.linesep)

+ 

+     sys.stdout.write(

+         "Refs marked as EXCLUDE are excluded by either refFilterInclusionRegex\n"

+         "or refFilterExclusionRegex. No emails will be sent for commits included\n"

+         "in these refs.\n"

+         "Refs marked as DONT-SEND are excluded by either refFilterDoSendRegex or\n"

+         "refFilterDontSendRegex, but not by either refFilterInclusionRegex or\n"

+         "refFilterExclusionRegex. Emails will be sent for commits included in these\n"

+         "refs only when the commit reaches a ref which isn't excluded.\n"

+         "Refs marked as DO-SEND are not excluded by any filter. Emails will\n"

+         "be sent normally for commits included in these refs.\n"

+     )

+ 

+     sys.stdout.write(os.linesep)

+ 

+     for refname in read_git_lines(["for-each-ref", "--format", "%(refname)"]):

+         sys.stdout.write(refname)

+         if not include_ref(refname, ref_filter_regex, ref_is_inclusion):

+             sys.stdout.write(" EXCLUDE")

+         elif not include_ref(refname, send_filter_regex, send_is_inclusion):

+             sys.stdout.write(" DONT-SEND")

+         else:

+             sys.stdout.write(" DO-SEND")

+ 

+         sys.stdout.write(os.linesep)

+ 

+ 

+ def show_env(environment, out):

+     out.write("Environment values:\n")

+     for (k, v) in sorted(environment.get_values().items()):

+         if k:  # Don't show the {'' : ''} pair.

+             out.write("    %s : %r\n" % (k, v))

+     out.write("\n")

+     # Flush to avoid interleaving with further log output

+     out.flush()

+ 

+ 

+ def check_setup(environment):

+     environment.check()

+     show_env(environment, sys.stdout)

+     sys.stdout.write(

+         "Now, checking that git-multimail's standard input "

+         "is properly set ..." + os.linesep

+     )

+     sys.stdout.write(

+         "Please type some text and then press Return" + os.linesep

+     )

+     stdin = sys.stdin.readline()

+     sys.stdout.write("You have just entered:" + os.linesep)

+     sys.stdout.write(stdin)

+     sys.stdout.write("git-multimail seems properly set up." + os.linesep)

+ 

+ 

+ def choose_mailer(config, environment):

+     mailer = config.get("mailer", default="sendmail")

+ 

+     if mailer == "smtp":

+         smtpserver = config.get("smtpserver", default="localhost")

+         smtpservertimeout = float(

+             config.get("smtpservertimeout", default=10.0)

+         )

+         smtpserverdebuglevel = int(

+             config.get("smtpserverdebuglevel", default=0)

+         )

+         smtpencryption = config.get("smtpencryption", default="none")

+         smtpuser = config.get("smtpuser", default="")

+         smtppass = config.get("smtppass", default="")

+         smtpcacerts = config.get("smtpcacerts", default="")

+         mailer = SMTPMailer(

+             environment,

+             envelopesender=(

+                 environment.get_sender() or environment.get_fromaddr()

+             ),

+             smtpserver=smtpserver,

+             smtpservertimeout=smtpservertimeout,

+             smtpserverdebuglevel=smtpserverdebuglevel,

+             smtpencryption=smtpencryption,

+             smtpuser=smtpuser,

+             smtppass=smtppass,

+             smtpcacerts=smtpcacerts,

+         )

+     elif mailer == "sendmail":

+         command = config.get("sendmailcommand")

+         if command:

+             command = shlex.split(command)

+         mailer = SendMailer(

+             environment,

+             command=command,

+             envelopesender=environment.get_sender(),

+         )

+     else:

+         environment.log_error(

+             'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n'

+             % mailer

+             + 'please use one of "smtp" or "sendmail".'

+         )

+         sys.exit(1)

+     return mailer

+ 

+ 

+ KNOWN_ENVIRONMENTS = {

+     "generic": {"highprec": GenericEnvironmentMixin},

+     "gitolite": {

+         "highprec": GitoliteEnvironmentHighPrecMixin,

+         "lowprec": GitoliteEnvironmentLowPrecMixin,

+     },

+     "stash": {

+         "highprec": StashEnvironmentHighPrecMixin,

+         "lowprec": StashEnvironmentLowPrecMixin,

+     },

+     "gerrit": {

+         "highprec": GerritEnvironmentHighPrecMixin,

+         "lowprec": GerritEnvironmentLowPrecMixin,

+     },

+ }

+ 

+ 

+ def choose_environment(

+     config, osenv=None, env=None, recipients=None, hook_info=None

+ ):

+     env_name = choose_environment_name(config, env, osenv)

+     environment_klass = build_environment_klass(env_name)

+     env = build_environment(

+         environment_klass, env_name, config, osenv, recipients, hook_info

+     )

+     return env

+ 

+ 

+ def choose_environment_name(config, env, osenv):

+     if not osenv:

+         osenv = os.environ

+ 

+     if not env:

+         env = config.get("environment")

+ 

+     if not env:

+         if "GL_USER" in osenv and "GL_REPO" in osenv:

+             env = "gitolite"

+         else:

+             env = "generic"

+     return env

+ 

+ 

+ COMMON_ENVIRONMENT_MIXINS = [

+     ConfigRecipientsEnvironmentMixin,

+     CLIRecipientsEnvironmentMixin,

+     ConfigRefFilterEnvironmentMixin,

+     ProjectdescEnvironmentMixin,

+     ConfigMaxlinesEnvironmentMixin,

+     ComputeFQDNEnvironmentMixin,

+     ConfigFilterLinesEnvironmentMixin,

+     PusherDomainEnvironmentMixin,

+     ConfigOptionsEnvironmentMixin,

+ ]

+ 

+ 

+ def build_environment_klass(env_name):

+     if "class" in KNOWN_ENVIRONMENTS[env_name]:

+         return KNOWN_ENVIRONMENTS[env_name]["class"]

+ 

+     environment_mixins = []

+     known_env = KNOWN_ENVIRONMENTS[env_name]

+     if "highprec" in known_env:

+         high_prec_mixin = known_env["highprec"]

+         environment_mixins.append(high_prec_mixin)

+     environment_mixins = environment_mixins + COMMON_ENVIRONMENT_MIXINS

+     if "lowprec" in known_env:

+         low_prec_mixin = known_env["lowprec"]

+         environment_mixins.append(low_prec_mixin)

+     environment_mixins.append(Environment)

+     klass_name = env_name.capitalize() + "Environement"

+     environment_klass = type(klass_name, tuple(environment_mixins), {})

+     KNOWN_ENVIRONMENTS[env_name]["class"] = environment_klass

+     return environment_klass

+ 

+ 

+ GerritEnvironment = build_environment_klass("gerrit")

+ StashEnvironment = build_environment_klass("stash")

+ GitoliteEnvironment = build_environment_klass("gitolite")

+ GenericEnvironment = build_environment_klass("generic")

+ 

+ 

+ def build_environment(

+     environment_klass, env, config, osenv, recipients, hook_info

+ ):

+     environment_kw = {"osenv": osenv, "config": config}

+ 

+     if env == "stash":

+         environment_kw["user"] = hook_info["stash_user"]

+         environment_kw["repo"] = hook_info["stash_repo"]

+     elif env == "gerrit":

+         environment_kw["project"] = hook_info["project"]

+         environment_kw["submitter"] = hook_info["submitter"]

+         environment_kw["update_method"] = hook_info["update_method"]

+ 

+     environment_kw["cli_recipients"] = recipients

+ 

+     return environment_klass(**environment_kw)

+ 

+ 

+ def get_version():

+     oldcwd = os.getcwd()

+     try:

          try:

-             hostname = gethostname()

-             maildomain = '.'.join(hostname.split('.')[1:])

+             os.chdir(os.path.dirname(os.path.realpath(__file__)))

+             git_version = read_git_output(["describe", "--tags", "HEAD"])

+             if git_version == __version__:

+                 return git_version

+             else:

+                 return "%s (%s)" % (__version__, git_version)

          except:

              pass

-         if not maildomain or '.' not in maildomain:

-             maildomain = 'localhost.localdomain'

+     finally:

+         os.chdir(oldcwd)

+     return __version__

+ 

+ 

+ def compute_gerrit_options(

+     options, args, required_gerrit_options, raw_refname

+ ):

+     if None in required_gerrit_options:

+         raise SystemExit(

+             "Error: Specify all of --oldrev, --newrev, --refname, "

+             "and --project; or none of them."

+         )

+ 

+     if options.environment not in (None, "gerrit"):

+         raise SystemExit(

+             "Non-gerrit environments incompatible with --oldrev, "

+             "--newrev, --refname, and --project"

+         )

+     options.environment = "gerrit"

+ 

+     if args:

+         raise SystemExit(

+             "Error: Positional parameters not allowed with "

+             "--oldrev, --newrev, and --refname."

+         )

+ 

+     # Gerrit oddly omits 'refs/heads/' in the refname when calling

+     # ref-updated hook; put it back.

+     git_dir = get_git_dir()

+     if not os.path.exists(

+         os.path.join(git_dir, raw_refname)

+     ) and os.path.exists(os.path.join(git_dir, "refs", "heads", raw_refname)):

+         options.refname = "refs/heads/" + options.refname

+ 

+     # New revisions can appear in a gerrit repository either due to someone

+     # pushing directly (in which case options.submitter will be set), or they

+     # can press "Submit this patchset" in the web UI for some CR (in which

+     # case options.submitter will not be set and gerrit will not have provided

+     # us the information about who pressed the button).

+     #

+     # Note for the nit-picky: I'm lumping in REST API calls and the ssh

+     # gerrit review command in with "Submit this patchset" button, since they

+     # have the same effect.

+     if options.submitter:

+         update_method = "pushed"

+         # The submitter argument is almost an RFC 2822 email address; change it

+         # from 'User Name (email@domain)' to 'User Name <email@domain>' so it is

+         options.submitter = options.submitter.replace("(", "<").replace(

+             ")", ">"

+         )

+     else:

+         update_method = "submitted"

+         # Gerrit knew who submitted this patchset, but threw that information

+         # away when it invoked this hook.  However, *IF* Gerrit created a

+         # merge to bring the patchset in (project 'Submit Type' is either

+         # "Always Merge", or is "Merge if Necessary" and happens to be

+         # necessary for this particular CR), then it will have the committer

+         # of that merge be 'Gerrit Code Review' and the author will be the

+         # person who requested the submission of the CR.  Since this is fairly

+         # likely for most gerrit installations (of a reasonable size), it's

+         # worth the extra effort to try to determine the actual submitter.

+         rev_info = read_git_lines(

+             [

+                 "log",

+                 "--no-walk",

+                 "--merges",

+                 "--format=%cN%n%aN <%aE>",

+                 options.newrev,

+             ]

+         )

+         if rev_info and rev_info[0] == "Gerrit Code Review":

+             options.submitter = rev_info[1]

+ 

+     # We pass back refname, oldrev, newrev as args because then the

+     # gerrit ref-updated hook is much like the git update hook

+     return (

+         options,

+         [options.refname, options.oldrev, options.newrev],

+         {

+             "project": options.project,

+             "submitter": options.submitter,

+             "update_method": update_method,

+         },

+     )

+ 

+ 

+ def check_hook_specific_args(options, args):

+     raw_refname = options.refname

+     # Convert each string option unicode for Python3.

+     if PYTHON3:

+         opts = [

+             "environment",

+             "recipients",

+             "oldrev",

+             "newrev",

+             "refname",

+             "project",

+             "submitter",

+             "stash_user",

+             "stash_repo",

+         ]

+         for opt in opts:

+             if not hasattr(options, opt):

+                 continue

+             obj = getattr(options, opt)

+             if obj:

+                 enc = obj.encode("utf-8", "surrogateescape")

+                 dec = enc.decode("utf-8", "replace")

+                 setattr(options, opt, dec)

+ 

+     # First check for stash arguments

+     if (options.stash_user is None) != (options.stash_repo is None):

+         raise SystemExit(

+             "Error: Specify both of --stash-user and "

+             "--stash-repo or neither."

+         )

+     if options.stash_user:

+         options.environment = "stash"

+         return (

+             options,

+             args,

+             {

+                 "stash_user": options.stash_user,

+                 "stash_repo": options.stash_repo,

+             },

+         )

+ 

+     # Finally, check for gerrit specific arguments

+     required_gerrit_options = (

+         options.oldrev,

+         options.newrev,

+         options.refname,

+         options.project,

+     )

+     if required_gerrit_options != (None,) * 4:

+         return compute_gerrit_options(

+             options, args, required_gerrit_options, raw_refname

+         )

+ 

+     # No special options in use, just return what we started with

+     return options, args, {}

+ 

+ 

+ class Logger(object):

+     def parse_verbose(self, verbose):

+         if verbose > 0:

+             return logging.DEBUG

+         else:

+             return logging.INFO

+ 

+     def create_log_file(self, environment, name, path, verbosity):

+         log_file = logging.getLogger(name)

+         file_handler = logging.FileHandler(path)

+         log_fmt = logging.Formatter(

+             "%(asctime)s [%(levelname)-5.5s]  %(message)s"

+         )

+         file_handler.setFormatter(log_fmt)

+         log_file.addHandler(file_handler)

+         log_file.setLevel(verbosity)

+         return log_file

+ 

+     def __init__(self, environment):

+         self.environment = environment

+         self.loggers = []

+         stderr_log = logging.getLogger("git_multimail.stderr")

+ 

+         class EncodedStderr(object):

+             def write(self, x):

+                 write_str(sys.stderr, x)

+ 

+             def flush(self):

+                 sys.stderr.flush()

+ 

+         stderr_handler = logging.StreamHandler(EncodedStderr())

+         stderr_log.addHandler(stderr_handler)

+         stderr_log.setLevel(self.parse_verbose(environment.verbose))

+         self.loggers.append(stderr_log)

+ 

+         if environment.debug_log_file is not None:

+             debug_log_file = self.create_log_file(

+                 environment,

+                 "git_multimail.debug",

+                 environment.debug_log_file,

+                 logging.DEBUG,

+             )

+             self.loggers.append(debug_log_file)

+ 

+         if environment.log_file is not None:

+             log_file = self.create_log_file(

+                 environment,

+                 "git_multimail.file",

+                 environment.log_file,

+                 logging.INFO,

+             )

+             self.loggers.append(log_file)

+ 

+         if environment.error_log_file is not None:

+             error_log_file = self.create_log_file(

+                 environment,

+                 "git_multimail.error",

+                 environment.error_log_file,

+                 logging.ERROR,

+             )

+             self.loggers.append(error_log_file)

+ 

+     def info(self, msg):

+         for l in self.loggers:

+             l.info(msg)

+ 

+     def debug(self, msg):

+         for l in self.loggers:

+             l.debug(msg)

+ 

+     def warning(self, msg):

+         for l in self.loggers:

+             l.warning(msg)

+ 

+     def error(self, msg):

+         for l in self.loggers:

+             l.error(msg)

+ 

+ 

+ def main(args):

+     parser = optparse.OptionParser(

+         description=__doc__,

+         usage="%prog [OPTIONS]\n   or: %prog [OPTIONS] REFNAME OLDREV NEWREV",

+     )

+ 

+     parser.add_option(

+         "--environment",

+         "--env",

+         action="store",

+         type="choice",

+         choices=list(KNOWN_ENVIRONMENTS.keys()),

+         default=None,

+         help=(

+             "Choose type of environment is in use.  Default is taken from "

+             'multimailhook.environment if set; otherwise "generic".'

+         ),

+     )

+     parser.add_option(

+         "--stdout",

+         action="store_true",

+         default=False,

+         help="Output emails to stdout rather than sending them.",

+     )

+     parser.add_option(

+         "--recipients",

+         action="store",

+         default=None,

+         help="Set list of email recipients for all types of emails.",

+     )

+     parser.add_option(

+         "--show-env",

+         action="store_true",

+         default=False,

+         help=(

+             "Write to stderr the values determined for the environment "

+             "(intended for debugging purposes), then proceed normally."

+         ),

+     )

+     parser.add_option(

+         "--force-send",

+         action="store_true",

+         default=False,

+         help=(

+             "Force sending refchange email when using as an update hook. "

+             "This is useful to work around the unreliable new commits "

+             "detection in this mode."

+         ),

+     )

+     parser.add_option(

+         "-c",

+         metavar="<name>=<value>",

+         action="append",

+         help=(

+             "Pass a configuration parameter through to git.  The value given "

+             "will override values from configuration files.  See the -c option "

+             "of git(1) for more details.  (Only works with git >= 1.7.3)"

+         ),

+     )

+     parser.add_option(

+         "--version",

+         "-v",

+         action="store_true",

+         default=False,

+         help=("Display git-multimail's version"),

+     )

+ 

+     parser.add_option(

+         "--python-version",

+         action="store_true",

+         default=False,

+         help=("Display the version of Python used by git-multimail"),

+     )

+ 

+     parser.add_option(

+         "--check-ref-filter",

+         action="store_true",

+         default=False,

+         help=(

+             "List refs and show information on how git-multimail "

+             "will process them."

+         ),

+     )

+ 

+     # The following options permit this script to be run as a gerrit

+     # ref-updated hook.  See e.g.

+     # code.google.com/p/gerrit/source/browse/Documentation/config-hooks.txt

+     # We suppress help for these items, since these are specific to gerrit,

+     # and we don't want users directly using them any way other than how the

+     # gerrit ref-updated hook is called.

+     parser.add_option("--oldrev", action="store", help=optparse.SUPPRESS_HELP)

+     parser.add_option("--newrev", action="store", help=optparse.SUPPRESS_HELP)

+     parser.add_option("--refname", action="store", help=optparse.SUPPRESS_HELP)

+     parser.add_option("--project", action="store", help=optparse.SUPPRESS_HELP)

+     parser.add_option(

+         "--submitter", action="store", help=optparse.SUPPRESS_HELP

+     )

+ 

+     # The following allow this to be run as a stash asynchronous post-receive

+     # hook (almost identical to a git post-receive hook but triggered also for

+     # merges of pull requests from the UI).  We suppress help for these items,

+     # since these are specific to stash.

+     parser.add_option(

+         "--stash-user", action="store", help=optparse.SUPPRESS_HELP

+     )

+     parser.add_option(

+         "--stash-repo", action="store", help=optparse.SUPPRESS_HELP

+     )

+ 

+     (options, args) = parser.parse_args(args)

+     (options, args, hook_info) = check_hook_specific_args(options, args)

+ 

+     if options.version:

+         sys.stdout.write("git-multimail version " + get_version() + "\n")

+         return

+ 

+     if options.python_version:

+         sys.stdout.write("Python version " + sys.version + "\n")

+         return

  

-     # Figure out a human-readable username

+     if options.c:

+         Config.add_config_parameters(options.c)

+ 

+     config = Config("multimailhook")

+ 

+     environment = None

      try:

-         entry = pwd.getpwuid(os.getuid())

-         gecos = entry.pw_gecos

-     except:

-         gecos = None

- 

-     if gecos != None:

-         # Typical GNOME account have John Doe <john.doe@example.com> for the GECOS.

-         # Comma-separated fields are also possible

-         m = re.match("([^,<]+)", gecos)

-         if m:

-             fullname = m.group(1).strip()

-             if fullname != "":

-                 try:

-                     user_fullname = unicode(fullname, 'ascii')

-                 except UnicodeDecodeError:

-                     user_fullname = Header(fullname, 'utf-8').encode()

+         environment = choose_environment(

+             config,

+             osenv=os.environ,

+             env=options.environment,

+             recipients=options.recipients,

+             hook_info=hook_info,

+         )

+ 

+         if options.show_env:

+             show_env(environment, sys.stderr)

+ 

+         if options.stdout or environment.stdout:

+             mailer = OutputMailer(sys.stdout)

+         else:

+             mailer = choose_mailer(config, environment)

+ 

+         must_check_setup = os.environ.get("GIT_MULTIMAIL_CHECK_SETUP")

+         if must_check_setup == "":

+             must_check_setup = False

+         if options.check_ref_filter:

+             check_ref_filter(environment)

+         elif must_check_setup:

+             check_setup(environment)

+         # Dual mode: if arguments were specified on the command line, run

+         # like an update hook; otherwise, run as a post-receive hook.

+         elif args:

+             if len(args) != 3:

+                 parser.error("Need zero or three non-option arguments")

+             (refname, oldrev, newrev) = args

+             environment.get_logger().debug(

+                 "run_as_update_hook: refname=%s, oldrev=%s, newrev=%s, force_send=%s"

+                 % (refname, oldrev, newrev, options.force_send)

+             )

+             run_as_update_hook(

+                 environment,

+                 mailer,

+                 refname,

+                 oldrev,

+                 newrev,

+                 options.force_send,

+             )

+         else:

+             run_as_post_receive_hook(environment, mailer)

+     except ConfigurationException:

+         sys.exit(sys.exc_info()[1])

+     except SystemExit:

+         raise

+     except Exception:

+         t, e, tb = sys.exc_info()

+         import traceback

+ 

+         sys.stderr.write("\n")  # Avoid mixing message with previous output

+         msg = (

+             "Exception '"

+             + t.__name__

+             + "' raised. Please report this as a bug to\n"

+             "https://github.com/git-multimail/git-multimail/issues\n"

+             "with the information below:\n\n"

+             "git-multimail version " + get_version() + "\n"

+             "Python version " + sys.version + "\n" + traceback.format_exc()

+         )

+         try:

+             environment.get_logger().error(msg)

+         except:

+             sys.stderr.write(msg)

+         sys.exit(1)

  

-     changes = []

  

-     if len(sys.argv) > 1:

-         # For testing purposes, allow passing in a ref update on the command line

-         if len(sys.argv) != 4:

-             die("Usage: generate-commit-mail OLDREV NEWREV REFNAME")

-         changes.append(make_change(sys.argv[1], sys.argv[2], sys.argv[3]))

-     else:

-         for line in sys.stdin:

-             items = line.strip().split()

-             if len(items) != 3:

-                 die("Input line has unexpected number of items")

-             changes.append(make_change(items[0], items[1], items[2]))

- 

-     for change in changes:

-         all_changes[change.refname] = change

- 

-     for change in changes:

-         change.prepare()

-         change.send_emails()

-         processed_changes[change.refname] = change

- 

- if __name__ == '__main__':

-     main()

+ if __name__ == "__main__":

+     main(sys.argv[1:])

Signed-off-by: Pierre-Yves Chibon pingou@pingoured.fr

rebased onto 6897949ef6bab69f1d9eb4a6eedddb6e7081d645

3 years ago

rebased onto 6897949ef6bab69f1d9eb4a6eedddb6e7081d645

3 years ago

rebased onto 155094f

3 years ago

rebased onto 155094f

3 years ago

Thats a pile of changes... but I'm game to merge and debug from there. Would be good to be on a more modern script.

Pull-Request has been merged by kevin

3 years ago