{"thread":{"id":"39666","subject":"[PATCH] git-multimail: update to release 1.1.0","startedAt":"2015-06-18T08:46:04Z","lastAt":"2015-06-18T08:46:04Z","messageCount":1,"participants":["Matthieu Moy"],"isPatch":true,"patchVersion":1,"patchTotal":null},"messages":[{"id":"264112","messageId":"1434617164-9417-1-git-send-email-Matthieu.Moy@imag.fr","threadId":"39666","inReplyTo":null,"subject":"[PATCH] git-multimail: update to release 1.1.0","fromName":"Matthieu Moy","fromEmail":"matthieu.moy@imag.fr","sentAt":"2015-06-18T08:46:04Z","receivedAt":"2015-06-18T08:46:04Z","isPatch":true,"sender":{"key":"git@matthieu-moy.fr","avatar":"https://avatars.githubusercontent.com/u/14709?v=4"},"body":"The changes are described in CHANGES.\n\nContributions-by: Matthieu Moy <Matthieu.Moy@imag.fr>\nContributions-by: Richard Hansen <rhansen@rhansen.org>\nContributions-by: Michael Haggerty <mhagger@alum.mit.edu>\nContributions-by: Elijah Newren <newren@gmail.com>\nContributions-by: Luke Mewburn <luke@mewburn.net>\nContributions-by: Dave Boutcher <daveboutcher@gmail.com>\nContributions-by: Azat Khuzhin <a3at.mail@gmail.com>\nContributions-by: Sebastian Schuberth <sschuberth@gmail.com>\nContributions-by: Mikko Johannes Koivunalho <mikko.koivunalho@iki.fi>\nContributions-by: Elijah Newren <newren@palantir.com>\nContributions-by: Benoît Ryder <benoit@ryder.fr>\nSigned-off-by: Matthieu Moy <Matthieu.Moy@imag.fr>\n---\n contrib/hooks/multimail/CHANGES                    |  48 +\n contrib/hooks/multimail/README                     | 279 ++++--\n contrib/hooks/multimail/README.Git                 |   4 +-\n contrib/hooks/multimail/git_multimail.py           | 967 ++++++++++++++++-----\n contrib/hooks/multimail/migrate-mailhook-config    |  18 +-\n .../{post-receive => post-receive.example}         |  27 +-\n 6 files changed, 1017 insertions(+), 326 deletions(-)\n rename contrib/hooks/multimail/{post-receive => post-receive.example} (74%)\n\ndiff --git a/contrib/hooks/multimail/CHANGES b/contrib/hooks/multimail/CHANGES\nindex 3603d56..0b823d8 100644\n--- a/contrib/hooks/multimail/CHANGES\n+++ b/contrib/hooks/multimail/CHANGES\n@@ -1,3 +1,51 @@\n+Release 1.1.0\n+=============\n+\n+* When a single commit is pushed, omit the reference changed email.\n+  Set multimailhook.combineWhenSingleCommit to false to disable this\n+  new feature.\n+\n+* In gitolite environments, the pusher's email address can be used as\n+  the From address by creating a specially formatted comment block in\n+  gitolite.conf (see multimailhook.from in README).\n+\n+* Support for SMTP authentication and SSL/TLS encryption was added,\n+  see smtpUser, smtpPass, smtpEncryption in README.\n+\n+* A new option scanCommitForCc was added to allow git-multimail to\n+  search the commit message for 'Cc: ...' lines, and add the\n+  corresponding emails in Cc.\n+\n+* If $USER is not set, use the variable $USERNAME. This is needed on\n+  Windows platform to recognize the pusher.\n+\n+* The emailPrefix variable can now be set to an empty string to remove\n+  the prefix.\n+\n+* A short tutorial was added in doc/gitolite.rst to set up\n+  git-multimail with gitolite.\n+\n+* The post-receive file was renamed to post-receive.example. It has\n+  always been an example (the standard way to call git-multimail is to\n+  call git_multimail.py), but it was unclear to many users.\n+\n+* A new refchangeShowGraph option was added to make it possible to\n+  include both a graph and a log in the summary emails.  The options\n+  to control the graph formatting can be set via the new graphOpts\n+  option.\n+\n+* New option --force-send was added to disable new commit detection\n+  for update hook. One use-case is to run git_multimail.py after\n+  running \"git fetch\" to send emails about commits that have just been\n+  fetched (the detection of new commits was unreliable in this mode).\n+\n+* The testing infrastructure was considerably improved (continuous\n+  integration with travis-ci, automatic check of PEP8 and RST syntax,\n+  many improvements to the test scripts).\n+\n+This version has been tested with Python 2.4 to 2.7, and Git 1.7.1 to\n+2.4.\n+\n Release 1.0.0\n =============\n \ndiff --git a/contrib/hooks/multimail/README b/contrib/hooks/multimail/README\nindex 6efa4ff..3a33cb7 100644\n--- a/contrib/hooks/multimail/README\n+++ b/contrib/hooks/multimail/README\n@@ -1,5 +1,8 @@\n-\t\t\t   git-multimail\n-\t\t\t   =============\n+git-multimail Version 1.1.0\n+===========================\n+\n+.. image:: https://travis-ci.org/git-multimail/git-multimail.svg?branch=master\n+    :target: https://travis-ci.org/git-multimail/git-multimail\n \n git-multimail is a tool for sending notification emails on pushes to a\n Git repository.  It includes a Python module called git_multimail.py,\n@@ -38,17 +41,17 @@ By default, for each push received by the repository, git-multimail:\n    list) makes it easy to scan through the emails, jump to patches\n    that need further attention, and write comments about specific\n    commits.  Commits are handled in reverse topological order (i.e.,\n-   parents shown before children).  For example,\n-\n-   [git] branch master updated\n-   + [git] 01/08: doc: fix xref link from api docs to manual pages\n-   + [git] 02/08: api-credentials.txt: show the big picture first\n-   + [git] 03/08: api-credentials.txt: mention credential.helper explicitly\n-   + [git] 04/08: api-credentials.txt: add \"see also\" section\n-   + [git] 05/08: t3510 (cherry-pick-sequence): add missing '&&'\n-   + [git] 06/08: Merge branch 'rr/maint-t3510-cascade-fix'\n-   + [git] 07/08: Merge branch 'mm/api-credentials-doc'\n-   + [git] 08/08: Git 1.7.11-rc2\n+   parents shown before children).  For example::\n+\n+     [git] branch master updated\n+     + [git] 01/08: doc: fix xref link from api docs to manual pages\n+     + [git] 02/08: api-credentials.txt: show the big picture first\n+     + [git] 03/08: api-credentials.txt: mention credential.helper explicitly\n+     + [git] 04/08: api-credentials.txt: add \"see also\" section\n+     + [git] 05/08: t3510 (cherry-pick-sequence): add missing '&&'\n+     + [git] 06/08: Merge branch 'rr/maint-t3510-cascade-fix'\n+     + [git] 07/08: Merge branch 'mm/api-credentials-doc'\n+     + [git] 08/08: Git 1.7.11-rc2\n \n    Each commit appears in exactly one commit email, the first time\n    that it is pushed to the repository.  If a commit is later merged\n@@ -74,19 +77,19 @@ Requirements\n   3.x.\n \n   The example scripts invoke Python using the following shebang line\n-  (following PEP 394 [1]):\n+  (following PEP 394 [1]_)::\n \n       #! /usr/bin/env python2\n \n   If your system's Python2 interpreter is not in your PATH or is not\n-  called \"python2\", you can change the lines accordingly.  Or you can\n+  called ``python2``, you can change the lines accordingly.  Or you can\n   invoke the Python interpreter explicitly, for example via a tiny\n-  shell script like\n+  shell script like::\n \n       #! /bin/sh\n       /usr/local/bin/python /path/to/git_multimail.py \"$@\"\n \n-* The \"git\" command must be in your PATH.  git-multimail is known to\n+* The ``git`` command must be in your PATH.  git-multimail is known to\n   work with Git versions back to 1.7.1.  (Earlier versions have not\n   been tested; if you do so, please report your results.)\n \n@@ -101,7 +104,7 @@ Requirements\n Invocation\n ----------\n \n-git_multimail.py is designed to be used as a \"post-receive\" hook in a\n+git_multimail.py is designed to be used as a ``post-receive`` hook in a\n Git repository (see githooks(5)).  Link or copy it to\n $GIT_DIR/hooks/post-receive within the repository for which email\n notifications are desired.  Usually it should be installed on the\n@@ -109,10 +112,10 @@ central repository for a project, to which all commits are eventually\n pushed.\n \n For use on pre-v1.5.1 Git servers, git_multimail.py can also work as\n-an \"update\" hook, taking its arguments on the command line.  To use\n+an ``update`` hook, taking its arguments on the command line.  To use\n this script in this manner, link or copy it to $GIT_DIR/hooks/update.\n Please note that the script is not completely reliable in this mode\n-[2].\n+[2]_.\n \n Alternatively, git_multimail.py can be imported as a Python module\n into your own Python post-receive script.  This method is a bit more\n@@ -129,7 +132,7 @@ arbitrary Python code.  For example, you can use a custom environment\n   only about changes affecting particular files or subdirectories)\n \n Or you can change how emails are sent by writing your own Mailer\n-class.  The \"post-receive\" script in this directory demonstrates how\n+class.  The ``post-receive`` script in this directory demonstrates how\n to use git_multimail.py as a Python module.  (If you make interesting\n changes of this type, please consider sharing them with the\n community.)\n@@ -139,18 +142,26 @@ Configuration\n -------------\n \n By default, git-multimail mostly takes its configuration from the\n-following \"git config\" settings:\n+following ``git config`` settings:\n \n multimailhook.environment\n \n     This describes the general environment of the repository.\n     Currently supported values:\n \n-    \"generic\" -- the username of the pusher is read from $USER and the\n-        repository name is derived from the repository's path.\n+    * generic\n+\n+      the username of the pusher is read from $USER or $USERNAME and\n+      the repository name is derived from the repository's path.\n+\n+    * gitolite\n \n-    \"gitolite\" -- the username of the pusher is read from $GL_USER and\n-        the repository name from $GL_REPO.\n+      the username of the pusher is read from $GL_USER, the repository\n+      name is read from $GL_REPO, and the From: header value is\n+      optionally read from gitolite.conf (see multimailhook.from).\n+\n+      For more information about gitolite and git-multimail, read\n+      doc/gitolite.rst\n \n     If neither of these environments is suitable for your setup, then\n     you can implement a Python class that inherits from Environment\n@@ -160,8 +171,8 @@ multimailhook.environment\n     The environment value can be specified on the command line using\n     the --environment option.  If it is not specified on the command\n     line or by multimailhook.environment, then it defaults to\n-    \"gitolite\" if the environment contains variables $GL_USER and\n-    $GL_REPO; otherwise \"generic\".\n+    ``gitolite`` if the environment contains variables $GL_USER and\n+    $GL_REPO; otherwise ``generic``.\n \n multimailhook.repoName\n \n@@ -219,61 +230,109 @@ multimailhook.announceShortlog\n     not so straightforward, then the shortlog might be confusing\n     rather than useful.  Default is false.\n \n+multimailhook.refchangeShowGraph\n+\n+    If this option is set to true, then summary emails about reference\n+    changes will additionally include:\n+\n+    * a graph of the added commits (if any)\n+\n+    * a graph of the discarded commits (if any)\n+\n+    The log is generated by running ``git log --graph`` with the options\n+    specified in graphOpts.  The default is false.\n+\n multimailhook.refchangeShowLog\n \n     If this option is set to true, then summary emails about reference\n     changes will include a detailed log of the added commits in\n     addition to the one line summary.  The log is generated by running\n-    \"git log\" with the options specified in multimailhook.logOpts.\n+    ``git log`` with the options specified in multimailhook.logOpts.\n     Default is false.\n \n multimailhook.mailer\n \n     This option changes the way emails are sent.  Accepted values are:\n \n-    - sendmail (the default): use the command /usr/sbin/sendmail or\n-      /usr/lib/sendmail (or sendmailCommand, if configured).  This\n+    - sendmail (the default): use the command ``/usr/sbin/sendmail`` or\n+      ``/usr/lib/sendmail`` (or sendmailCommand, if configured).  This\n       mode can be further customized via the following options:\n \n-       multimailhook.sendmailCommand\n+      * multimailhook.sendmailCommand\n \n-           The command used by mailer \"sendmail\" to send emails.  Shell\n-           quoting is allowed in the value of this setting, but remember that\n-           Git requires double-quotes to be escaped; e.g.,\n+        The command used by mailer ``sendmail`` to send emails.  Shell\n+        quoting is allowed in the value of this setting, but remember that\n+        Git requires double-quotes to be escaped; e.g.::\n \n              git config multimailhook.sendmailcommand '/usr/sbin/sendmail -oi -t -F \\\"Git Repo\\\"'\n \n-           Default is '/usr/sbin/sendmail -oi -t' or\n-           '/usr/lib/sendmail -oi -t' (depending on which file is\n-           present and executable).\n+        Default is '/usr/sbin/sendmail -oi -t' or\n+        '/usr/lib/sendmail -oi -t' (depending on which file is\n+        present and executable).\n \n-       multimailhook.envelopeSender\n+      * multimailhook.envelopeSender\n \n-           If set then pass this value to sendmail via the -f option to set\n-           the envelope sender address.\n+        If set then pass this value to sendmail via the -f option to set\n+        the envelope sender address.\n \n     - smtp: use Python's smtplib.  This is useful when the sendmail\n       command is not available on the system.  This mode can be\n       further customized via the following options:\n \n-       multimailhook.smtpServer\n+      * multimailhook.smtpServer\n+\n+        The name of the SMTP server to connect to.  The value can\n+        also include a colon and a port number; e.g.,\n+        ``mail.example.com:25``.  Default is 'localhost' using port 25.\n+\n+      * multimailhook.smtpUser\n+      * multimailhook.smtpPass\n+\n+        Server username and password. Required if smtpEncryption is 'ssl'.\n+        Note that the username and password currently need to be\n+        set cleartext in the configuration file, which is not\n+        recommended. If you need to use this option, be sure your\n+        configuration file is read-only.\n+\n+      * multimailhook.envelopeSender\n+\n+        The sender address to be passed to the SMTP server.  If\n+        unset, then the value of multimailhook.from is used.\n+\n+      * multimailhook.smtpServerTimeout\n+\n+        Timeout in seconds.\n \n-           The name of the SMTP server to connect to.  The value can\n-           also include a colon and a port number; e.g.,\n-           \"mail.example.com:25\".  Default is 'localhost' using port\n-           25.\n+      * multimailhook.smtpEncryption\n \n-       multimailhook.envelopeSender\n+        Set the security type. Allowed values: none, ssl.\n+        Default=none.\n \n-           The sender address to be passed to the SMTP server.  If\n-           unset, then the value of multimailhook.from is used.\n+      * multimailhook.smtpServerDebugLevel\n+\n+        Integer number. Set to greater than 0 to activate debugging.\n \n multimailhook.from\n \n-    If set then use this value in the From: field of generated emails.\n-    If unset, then use the repository's user configuration (user.name\n-    and user.email).  If user.email is also unset, then use\n-    multimailhook.envelopeSender.\n+    If set, use this value in the From: field of generated emails.  If\n+    unset, the value of the From: header is determined as follows:\n+\n+    1. (gitolite environment only) Parse gitolite.conf, looking for a\n+       block of comments that looks like this::\n+\n+           # BEGIN USER EMAILS\n+           # username Firstname Lastname <email@example.com>\n+           # END USER EMAILS\n+\n+       If that block exists, and there is a line between the BEGIN\n+       USER EMAILS and END USER EMAILS lines where the first field\n+       matches the gitolite username ($GL_USER), use the rest of the\n+       line for the From: header.\n+\n+    2. If the user.email configuration setting is set, use its value\n+       (and the value of user.name, if set).\n+\n+    3. Use the value of multimailhook.envelopeSender.\n \n multimailhook.administrator\n \n@@ -287,7 +346,8 @@ multimailhook.emailPrefix\n     All emails have this string prepended to their subjects, to aid\n     email filtering (though filtering based on the X-Git-* email\n     headers is probably more robust).  Default is the short name of\n-    the repository in square brackets; e.g., \"[myrepo]\".\n+    the repository in square brackets; e.g., ``[myrepo]``.  Set this\n+    value to the empty string to suppress the email prefix.\n \n multimailhook.emailMaxLines\n \n@@ -299,7 +359,7 @@ multimailhook.emailMaxLines\n multimailhook.emailMaxLineLength\n \n     The maximum length of a line in the email body.  Lines longer than\n-    this limit are truncated to this length with a trailing \" [...]\"\n+    this limit are truncated to this length with a trailing `` [...]``\n     added to indicate the missing text.  The default is 500, because\n     (a) diffs with longer lines are probably from binary files, for\n     which a diff is useless, and (b) even if a text file has such long\n@@ -316,54 +376,62 @@ multimailhook.maxCommitEmails\n \n multimailhook.emailStrictUTF8\n \n-    If this boolean option is set to \"true\", then the main part of the\n+    If this boolean option is set to `true`, then the main part of the\n     email body is forced to be valid UTF-8.  Any characters that are\n     not valid UTF-8 are converted to the Unicode replacement\n-    character, U+FFFD.  The default is \"true\".\n+    character, U+FFFD.  The default is `true`.\n \n multimailhook.diffOpts\n \n-    Options passed to \"git diff-tree\" when generating the summary\n-    information for ReferenceChange emails.  Default is \"--stat\n-    --summary --find-copies-harder\".  Add -p to those options to\n+    Options passed to ``git diff-tree`` when generating the summary\n+    information for ReferenceChange emails.  Default is ``--stat\n+    --summary --find-copies-harder``.  Add -p to those options to\n     include a unified diff of changes in addition to the usual summary\n     output.  Shell quoting is allowed; see multimailhook.logOpts for\n     details.\n \n+multimailhook.graphOpts\n+\n+    Options passed to ``git log --graph`` when generating graphs for the\n+    reference change summary emails (used only if refchangeShowGraph\n+    is true).  The default is '--oneline --decorate'.\n+\n+    Shell quoting is allowed; see logOpts for details.\n+\n multimailhook.logOpts\n \n-    Options passed to \"git log\" to generate additional info for\n+    Options passed to ``git log`` to generate additional info for\n     reference change emails (used only if refchangeShowLog is set).\n-    For example, adding --graph will show the graph of revisions, -p\n-    will show the complete diff, etc.  The default is empty.\n+    For example, adding -p will show each commit's complete diff.  The\n+    default is empty.\n \n     Shell quoting is allowed; for example, a log format that contains\n-    spaces can be specified using something like:\n+    spaces can be specified using something like::\n \n       git config multimailhook.logopts '--pretty=format:\"%h %aN <%aE>%n%s%n%n%b%n\"'\n \n     If you want to set this by editing your configuration file\n     directly, remember that Git requires double-quotes to be escaped\n-    (see git-config(1) for more information):\n+    (see git-config(1) for more information)::\n \n       [multimailhook]\n               logopts = --pretty=format:\\\"%h %aN <%aE>%n%s%n%n%b%n\\\"\n \n multimailhook.commitLogOpts\n \n-    Options passed to \"git log\" to generate additional info for\n+    Options passed to ``git log`` to generate additional info for\n     revision change emails.  For example, adding --ignore-all-spaces\n-    will suppress whitespace changes.  The default options are \"-C\n-    --stat -p --cc\".  Shell quoting is allowed; see\n+    will suppress whitespace changes.  The default options are ``-C\n+    --stat -p --cc``.  Shell quoting is allowed; see\n     multimailhook.logOpts for details.\n \n multimailhook.emailDomain\n \n     Domain name appended to the username of the person doing the push\n-    to convert it into an email address (via \"%s@%s\" % (username,\n-    emaildomain)).  More complicated schemes can be implemented by\n-    overriding Environment and overriding its get_pusher_email()\n-    method.\n+    to convert it into an email address\n+    (via ``\"%s@%s\" % (username, emaildomain)``). More complicated\n+    schemes can be implemented by overriding Environment and\n+    overriding its get_pusher_email() method.\n \n multimailhook.replyTo\n multimailhook.replyToCommit\n@@ -377,26 +445,48 @@ multimailhook.replyToRefchange\n \n     - An email address, which will be used directly.\n \n-    - The value \"pusher\", in which case the pusher's address (if\n+    - The value `pusher`, in which case the pusher's address (if\n       available) will be used.  This is the default for refchange\n       emails.\n \n-    - The value \"author\" (meaningful only for replyToCommit), in which\n+    - The value `author` (meaningful only for replyToCommit), in which\n       case the commit author's address will be used.  This is the\n       default for commit emails.\n \n-    - The value \"none\", in which case the Reply-To: field will be\n+    - The value `none`, in which case the Reply-To: field will be\n       omitted.\n \n+multimailhook.quiet\n+\n+    Do not output the list of email recipients from the hook\n+\n+multimailhook.stdout\n+\n+    For debugging, send emails to stdout rather than to the\n+    mailer.  Equivalent to the --stdout command line option\n+\n+multimailhook.scanCommitForCc\n+\n+    If this option is set to true, than recipients from lines in commit body\n+    that starts with ``CC:`` will be added to CC list.\n+    Default: false\n+\n+multimailhook.combineWhenSingleCommit\n+\n+    If this option is set to true and a single new commit is pushed to\n+    a branch, combine the summary and commit email messages into a\n+    single email.\n+    Default: true\n+\n \n Email filtering aids\n --------------------\n \n All emails include extra headers to enable fine tuned filtering and\n give information for debugging.  All emails include the headers\n-\"X-Git-Host\", \"X-Git-Repo\", \"X-Git-Refname\", and \"X-Git-Reftype\".\n-ReferenceChange emails also include headers \"X-Git-Oldrev\" and \"X-Git-Newrev\";\n-Revision emails also include header \"X-Git-Rev\".\n+``X-Git-Host``, ``X-Git-Repo``, ``X-Git-Refname``, and ``X-Git-Reftype``.\n+ReferenceChange emails also include headers ``X-Git-Oldrev`` and ``X-Git-Newrev``;\n+Revision emails also include header ``X-Git-Rev``.\n \n \n Customizing email contents\n@@ -420,16 +510,17 @@ environment are built in:\n * GenericEnvironment: a stand-alone Git repository.\n \n * GitoliteEnvironment: a Git repository that is managed by gitolite\n-  [3].  For such repositories, the identity of the pusher is read from\n-  environment variable $GL_USER, and the name of the repository is\n-  read from $GL_REPO (if it is not overridden by\n-  multimailhook.reponame).\n+  [3]_.  For such repositories, the identity of the pusher is read from\n+  environment variable $GL_USER, the name of the repository is read\n+  from $GL_REPO (if it is not overridden by multimailhook.reponame),\n+  and the From: header value is optionally read from gitolite.conf\n+  (see multimailhook.from).\n \n By default, git-multimail assumes GitoliteEnvironment if $GL_USER and\n $GL_REPO are set, and otherwise assumes GenericEnvironment.\n Alternatively, you can choose one of these two environments explicitly\n-by setting a \"multimailhook.environment\" config setting (which can\n-have the value \"generic\" or \"gitolite\") or by passing an --environment\n+by setting a ``multimailhook.environment`` config setting (which can\n+have the value `generic` or `gitolite`) or by passing an --environment\n option to the script.\n \n If you need to customize the script in ways that are not supported by\n@@ -439,8 +530,8 @@ git_multimail.py as a Python module, as demonstrated by the example\n post-receive script.  Then implement your environment class; it should\n usually inherit from one of the existing Environment classes and\n possibly one or more of the EnvironmentMixin classes.  Then set the\n-\"environment\" variable to an instance of your own environment class\n-and pass it to run_as_post_receive_hook().\n+``environment`` variable to an instance of your own environment class\n+and pass it to ``run_as_post_receive_hook()``.\n \n The standard environment classes, GenericEnvironment and\n GitoliteEnvironment, are in fact themselves put together out of a\n@@ -490,12 +581,14 @@ don't overlook them.\n Footnotes\n ---------\n \n-[1] http://www.python.org/dev/peps/pep-0394/\n+.. [1] http://www.python.org/dev/peps/pep-0394/\n \n-[2] Because of the way information is passed to update hooks, the\n-    script's method of determining whether a commit has already been\n-    seen does not work when it is used as an \"update\" script.  In\n-    particular, no notification email will be generated for a new\n-    commit that is added to multiple references in the same push.\n+.. [2] Because of the way information is passed to update hooks, the\n+       script's method of determining whether a commit has already\n+       been seen does not work when it is used as an ``update`` script.\n+       In particular, no notification email will be generated for a\n+       new commit that is added to multiple references in the same\n+       push. A workaround is to use --force-send to force sending the\n+       emails.\n \n-[3] https://github.com/sitaramc/gitolite\n+.. [3] https://github.com/sitaramc/gitolite\ndiff --git a/contrib/hooks/multimail/README.Git b/contrib/hooks/multimail/README.Git\nindex ab3ece5..449d36f 100644\n--- a/contrib/hooks/multimail/README.Git\n+++ b/contrib/hooks/multimail/README.Git\n@@ -6,10 +6,10 @@ website:\n     https://github.com/git-multimail/git-multimail\n \n The version in this directory was obtained from the upstream project\n-on 2015-04-27 and consists of the \"git-multimail\" subdirectory from\n+on Jun 18 2015 and consists of the \"git-multimail\" subdirectory from\n revision\n \n-    8c3aaafa873bf10de8dddf1d202c449b3eff3b42 refs/tags/1.0.2\n+    1f0dbb3b60035767889b913df16d9231ecdb8709 refs/tags/1.1.0\n \n Please see the README file in this directory for information about how\n to report bugs or contribute to git-multimail.\ndiff --git a/contrib/hooks/multimail/git_multimail.py b/contrib/hooks/multimail/git_multimail.py\nindex 8b58ed6..7cb2b36 100755\n--- a/contrib/hooks/multimail/git_multimail.py\n+++ b/contrib/hooks/multimail/git_multimail.py\n@@ -1,5 +1,6 @@\n #! /usr/bin/env python2\n \n+# Copyright (c) 2015 Matthieu Moy and others\n # Copyright (c) 2012-2014 Michael Haggerty and others\n # Derived from contrib/hooks/post-receive-email, which is\n # Copyright (c) 2007 Andy Parkins\n@@ -99,6 +100,10 @@ REF_DELETED_SUBJECT_TEMPLATE = (\n     ' (was %(oldrev_short)s)'\n     )\n \n+COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE = (\n+    '%(emailprefix)s%(refname_type)s %(short_refname)s updated: %(oneline)s'\n+    )\n+\n REFCHANGE_HEADER_TEMPLATE = \"\"\"\\\n Date: %(send_date)s\n To: %(recipients)s\n@@ -230,6 +235,7 @@ how to provide full information about this reference change.\n REVISION_HEADER_TEMPLATE = \"\"\"\\\n Date: %(send_date)s\n To: %(recipients)s\n+Cc: %(cc_recipients)s\n Subject: %(emailprefix)s%(num)02d/%(tot)02d: %(oneline)s\n MIME-Version: 1.0\n Content-Type: text/plain; charset=%(charset)s\n@@ -258,6 +264,38 @@ in repository %(repo_shortname)s.\n REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE\n \n \n+# Combined, meaning refchange+revision email (for single-commit additions)\n+COMBINED_HEADER_TEMPLATE = \"\"\"\\\n+Date: %(send_date)s\n+To: %(recipients)s\n+Subject: %(subject)s\n+MIME-Version: 1.0\n+Content-Type: text/plain; charset=%(charset)s\n+Content-Transfer-Encoding: 8bit\n+Message-ID: %(msgid)s\n+From: %(fromaddr)s\n+Reply-To: %(reply_to)s\n+X-Git-Host: %(fqdn)s\n+X-Git-Repo: %(repo_shortname)s\n+X-Git-Refname: %(refname)s\n+X-Git-Reftype: %(refname_type)s\n+X-Git-Oldrev: %(oldrev)s\n+X-Git-Newrev: %(newrev)s\n+X-Git-Rev: %(rev)s\n+Auto-Submitted: auto-generated\n+\"\"\"\n+\n+COMBINED_INTRO_TEMPLATE = \"\"\"\\\n+This is an automated email from the git hooks/post-receive script.\n+\n+%(pusher)s pushed a commit to %(refname_type)s %(short_refname)s\n+in repository %(repo_shortname)s.\n+\n+\"\"\"\n+\n+COMBINED_FOOTER_TEMPLATE = FOOTER_TEMPLATE\n+\n+\n class CommandError(Exception):\n     def __init__(self, cmd, retcode):\n         self.cmd = cmd\n@@ -336,6 +374,47 @@ def read_git_lines(args, keepends=False, **kw):\n     return read_git_output(args, keepends=True, **kw).splitlines(keepends)\n \n \n+def git_rev_list_ish(cmd, spec, args=None, **kw):\n+    \"\"\"Common functionality for invoking a 'git rev-list'-like command.\n+\n+    Parameters:\n+      * cmd is the Git command to run, e.g., 'rev-list' or 'log'.\n+      * spec is a list of revision arguments to pass to the named\n+        command.  If None, this function returns an empty list.\n+      * args is a list of extra arguments passed to the named command.\n+      * All other keyword arguments (if any) are passed to the\n+        underlying read_git_lines() function.\n+\n+    Return the output of the Git command in the form of a list, one\n+    entry per output line.\n+    \"\"\"\n+    if spec is None:\n+        return []\n+    if args is None:\n+        args = []\n+    args = [cmd, '--stdin'] + args\n+    spec_stdin = ''.join(s + '\\n' for s in spec)\n+    return read_git_lines(args, input=spec_stdin, **kw)\n+\n+\n+def git_rev_list(spec, **kw):\n+    \"\"\"Run 'git rev-list' with the given list of revision arguments.\n+\n+    See git_rev_list_ish() for parameter and return value\n+    documentation.\n+    \"\"\"\n+    return git_rev_list_ish('rev-list', spec, **kw)\n+\n+\n+def git_log(spec, **kw):\n+    \"\"\"Run 'git log' with the given list of revision arguments.\n+\n+    See git_rev_list_ish() for parameter and return value\n+    documentation.\n+    \"\"\"\n+    return git_rev_list_ish('log', spec, **kw)\n+\n+\n def header_encode(text, header_name=None):\n     \"\"\"Encode and line-wrap the value of an email header field.\"\"\"\n \n@@ -388,9 +467,9 @@ class Config(object):\n     def get(self, name, default=None):\n         try:\n             values = self._split(read_git_output(\n-                    ['config', '--get', '--null', '%s.%s' % (self.section, name)],\n-                    env=self.env, keepends=True,\n-                    ))\n+                ['config', '--get', '--null', '%s.%s' % (self.section, name)],\n+                env=self.env, keepends=True,\n+                ))\n             assert len(values) == 1\n             return values[0]\n         except CommandError:\n@@ -449,9 +528,14 @@ class Config(object):\n             env=self.env,\n             )\n \n-    def has_key(self, name):\n+    def __contains__(self, name):\n         return self.get_all(name, default=None) is not None\n \n+    # We don't use this method anymore internally, but keep it here in\n+    # case somebody is calling it from their own code:\n+    def has_key(self, name):\n+        return name in self\n+\n     def unset_all(self, name):\n         try:\n             read_git_output(\n@@ -579,7 +663,7 @@ class Change(object):\n         self._values = None\n \n     def _compute_values(self):\n-        \"\"\"Return a dictionary {keyword : expansion} for this Change.\n+        \"\"\"Return a dictionary {keyword: expansion} for this Change.\n \n         Derived classes overload this method to add more entries to\n         the return value.  This method is used internally by\n@@ -589,7 +673,7 @@ class Change(object):\n         return self.environment.get_values()\n \n     def get_values(self, **extra_values):\n-        \"\"\"Return a dictionary {keyword : expansion} for this Change.\n+        \"\"\"Return a dictionary {keyword: expansion} for this Change.\n \n         Return a dictionary mapping keywords to the values that they\n         should be expanded to for this Change (used when interpolating\n@@ -636,7 +720,7 @@ class Change(object):\n                 value = value % values\n             except KeyError, e:\n                 if DEBUG:\n-                    sys.stderr.write(\n+                    self.environment.log_warning(\n                         'Warning: unknown variable %r in the following line; line skipped:\\n'\n                         '    %s\\n'\n                         % (e.args[0], line,)\n@@ -711,6 +795,8 @@ class Change(object):\n class Revision(Change):\n     \"\"\"A Change consisting of a single git commit.\"\"\"\n \n+    CC_RE = re.compile(r'^\\s*C[Cc]:\\s*(?P<to>[^#]+@[^\\s#]*)\\s*(#.*)?$')\n+\n     def __init__(self, reference_change, rev, num, tot):\n         Change.__init__(self, reference_change.environment)\n         self.reference_change = reference_change\n@@ -722,6 +808,24 @@ class Revision(Change):\n         self.author = read_git_output(['log', '--no-walk', '--format=%aN <%aE>', self.rev.sha1])\n         self.recipients = self.environment.get_revision_recipients(self)\n \n+        self.cc_recipients = ''\n+        if self.environment.get_scancommitforcc():\n+            self.cc_recipients = ', '.join(to.strip() for to in self._cc_recipients())\n+            if self.cc_recipients:\n+                self.environment.log_msg(\n+                    'Add %s to CC for %s\\n' % (self.cc_recipients, self.rev.sha1))\n+\n+    def _cc_recipients(self):\n+        cc_recipients = []\n+        message = read_git_output(['log', '--no-walk', '--format=%b', self.rev.sha1])\n+        lines = message.strip().split('\\n')\n+        for line in lines:\n+            m = re.match(self.CC_RE, line)\n+            if m:\n+                cc_recipients.append(m.group('to'))\n+\n+        return cc_recipients\n+\n     def _compute_values(self):\n         values = Change._compute_values(self)\n \n@@ -739,6 +843,8 @@ class Revision(Change):\n         values['num'] = self.num\n         values['tot'] = self.tot\n         values['recipients'] = self.recipients\n+        if self.cc_recipients:\n+            values['cc_recipients'] = self.cc_recipients\n         values['oneline'] = oneline\n         values['author'] = self.author\n \n@@ -750,8 +856,8 @@ class Revision(Change):\n \n     def generate_email_header(self, **extra_values):\n         for line in self.expand_header_lines(\n-            REVISION_HEADER_TEMPLATE, **extra_values\n-            ):\n+                REVISION_HEADER_TEMPLATE, **extra_values\n+                ):\n             yield line\n \n     def generate_email_intro(self):\n@@ -822,26 +928,26 @@ class ReferenceChange(Change):\n                 klass = BranchChange\n             elif area == 'remotes':\n                 # Tracking branch:\n-                sys.stderr.write(\n+                environment.log_warning(\n                     '*** Push-update of tracking branch %r\\n'\n                     '***  - incomplete email generated.\\n'\n-                     % (refname,)\n+                    % (refname,)\n                     )\n                 klass = OtherReferenceChange\n             else:\n                 # Some other reference namespace:\n-                sys.stderr.write(\n+                environment.log_warning(\n                     '*** Push-update of strange reference %r\\n'\n                     '***  - incomplete email generated.\\n'\n-                     % (refname,)\n+                    % (refname,)\n                     )\n                 klass = OtherReferenceChange\n         else:\n             # Anything else (is there anything else?)\n-            sys.stderr.write(\n+            environment.log_warning(\n                 '*** Unknown type of update to %r (%s)\\n'\n                 '***  - incomplete email generated.\\n'\n-                 % (refname, rev.type,)\n+                % (refname, rev.type,)\n                 )\n             klass = OtherReferenceChange\n \n@@ -854,9 +960,9 @@ class ReferenceChange(Change):\n     def __init__(self, environment, refname, short_refname, old, new, rev):\n         Change.__init__(self, environment)\n         self.change_type = {\n-            (False, True) : 'create',\n-            (True, True) : 'update',\n-            (True, False) : 'delete',\n+            (False, True): 'create',\n+            (True, True): 'update',\n+            (True, False): 'delete',\n             }[bool(old), bool(new)]\n         self.refname = refname\n         self.short_refname = short_refname\n@@ -865,10 +971,16 @@ class ReferenceChange(Change):\n         self.rev = rev\n         self.msgid = make_msgid()\n         self.diffopts = environment.diffopts\n+        self.graphopts = environment.graphopts\n         self.logopts = environment.logopts\n         self.commitlogopts = environment.commitlogopts\n+        self.showgraph = environment.refchange_showgraph\n         self.showlog = environment.refchange_showlog\n \n+        self.header_template = REFCHANGE_HEADER_TEMPLATE\n+        self.intro_template = REFCHANGE_INTRO_TEMPLATE\n+        self.footer_template = FOOTER_TEMPLATE\n+\n     def _compute_values(self):\n         values = Change._compute_values(self)\n \n@@ -894,11 +1006,39 @@ class ReferenceChange(Change):\n \n         return values\n \n+    def send_single_combined_email(self, known_added_sha1s):\n+        \"\"\"Determine if a combined refchange/revision email should be sent\n+\n+        If there is only a single new (non-merge) commit added by a\n+        change, it is useful to combine the ReferenceChange and\n+        Revision emails into one.  In such a case, return the single\n+        revision; otherwise, return None.\n+\n+        This method is overridden in BranchChange.\"\"\"\n+\n+        return None\n+\n+    def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):\n+        \"\"\"Generate an email describing this change AND specified revision.\n+\n+        Iterate over the lines (including the header lines) of an\n+        email describing this change.  If body_filter is not None,\n+        then use it to filter the lines that are intended for the\n+        email body.\n+\n+        The extra_header_values field is received as a dict and not as\n+        **kwargs, to allow passing other keyword arguments in the\n+        future (e.g. passing extra values to generate_email_intro()\n+\n+        This method is overridden in BranchChange.\"\"\"\n+\n+        raise NotImplementedError\n+\n     def get_subject(self):\n         template = {\n-            'create' : REF_CREATED_SUBJECT_TEMPLATE,\n-            'update' : REF_UPDATED_SUBJECT_TEMPLATE,\n-            'delete' : REF_DELETED_SUBJECT_TEMPLATE,\n+            'create': REF_CREATED_SUBJECT_TEMPLATE,\n+            'update': REF_UPDATED_SUBJECT_TEMPLATE,\n+            'delete': REF_DELETED_SUBJECT_TEMPLATE,\n             }[self.change_type]\n         return self.expand(template)\n \n@@ -907,12 +1047,12 @@ class ReferenceChange(Change):\n             extra_values['subject'] = self.get_subject()\n \n         for line in self.expand_header_lines(\n-            REFCHANGE_HEADER_TEMPLATE, **extra_values\n-            ):\n+                self.header_template, **extra_values\n+                ):\n             yield line\n \n     def generate_email_intro(self):\n-        for line in self.expand_lines(REFCHANGE_INTRO_TEMPLATE):\n+        for line in self.expand_lines(self.intro_template):\n             yield line\n \n     def generate_email_body(self, push):\n@@ -922,9 +1062,9 @@ class ReferenceChange(Change):\n         generate_update_summary() / generate_delete_summary().\"\"\"\n \n         change_summary = {\n-            'create' : self.generate_create_summary,\n-            'delete' : self.generate_delete_summary,\n-            'update' : self.generate_update_summary,\n+            'create': self.generate_create_summary,\n+            'delete': self.generate_delete_summary,\n+            'update': self.generate_update_summary,\n             }[self.change_type](push)\n         for line in change_summary:\n             yield line\n@@ -933,7 +1073,23 @@ class ReferenceChange(Change):\n             yield line\n \n     def generate_email_footer(self):\n-        return self.expand_lines(FOOTER_TEMPLATE)\n+        return self.expand_lines(self.footer_template)\n+\n+    def generate_revision_change_graph(self, push):\n+        if self.showgraph:\n+            args = ['--graph'] + self.graphopts\n+            for newold in ('new', 'old'):\n+                has_newold = False\n+                spec = push.get_commits_spec(newold, self)\n+                for line in git_log(spec, args=args, keepends=True):\n+                    if not has_newold:\n+                        has_newold = True\n+                        yield '\\n'\n+                        yield 'Graph of %s commits:\\n\\n' % (\n+                            {'new': 'new', 'old': 'discarded'}[newold],)\n+                    yield '  ' + line\n+                if has_newold:\n+                    yield '\\n'\n \n     def generate_revision_change_log(self, new_commits_list):\n         if self.showlog:\n@@ -945,9 +1101,17 @@ class ReferenceChange(Change):\n                     + new_commits_list\n                     + ['--'],\n                     keepends=True,\n-                ):\n+                    ):\n                 yield line\n \n+    def generate_new_revision_summary(self, tot, new_commits_list, push):\n+        for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot):\n+            yield line\n+        for line in self.generate_revision_change_graph(push):\n+            yield line\n+        for line in self.generate_revision_change_log(new_commits_list):\n+            yield line\n+\n     def generate_revision_change_summary(self, push):\n         \"\"\"Generate a summary of the revisions added/removed by this change.\"\"\"\n \n@@ -960,7 +1124,7 @@ class ReferenceChange(Change):\n             sha1s.reverse()\n             tot = len(sha1s)\n             new_revisions = [\n-                Revision(self, GitObject(sha1), num=i+1, tot=tot)\n+                Revision(self, GitObject(sha1), num=i + 1, tot=tot)\n                 for (i, sha1) in enumerate(sha1s)\n                 ]\n \n@@ -973,9 +1137,8 @@ class ReferenceChange(Change):\n                         BRIEF_SUMMARY_TEMPLATE, action='new', text=subject,\n                         )\n                 yield '\\n'\n-                for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot):\n-                    yield line\n-                for line in self.generate_revision_change_log([r.rev.sha1 for r in new_revisions]):\n+                for line in self.generate_new_revision_summary(\n+                        tot, [r.rev.sha1 for r in new_revisions], push):\n                     yield line\n             else:\n                 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):\n@@ -993,16 +1156,16 @@ class ReferenceChange(Change):\n             # revisions in the summary even though we will not send\n             # new notification emails for them.\n             adds = list(generate_summaries(\n-                    '--topo-order', '--reverse', '%s..%s'\n-                    % (self.old.commit_sha1, self.new.commit_sha1,)\n-                    ))\n+                '--topo-order', '--reverse', '%s..%s'\n+                % (self.old.commit_sha1, self.new.commit_sha1,)\n+                ))\n \n             # List of the revisions that were removed from the branch\n             # by this update.  This will be empty except for\n             # non-fast-forward updates.\n             discards = list(generate_summaries(\n-                    '%s..%s' % (self.new.commit_sha1, self.old.commit_sha1,)\n-                    ))\n+                '%s..%s' % (self.new.commit_sha1, self.old.commit_sha1,)\n+                ))\n \n             if adds:\n                 new_commits_list = push.get_new_commits(self)\n@@ -1071,13 +1234,14 @@ class ReferenceChange(Change):\n             yield '\\n'\n \n             if new_commits:\n-                for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=len(new_commits)):\n-                    yield line\n-                for line in self.generate_revision_change_log(new_commits_list):\n+                for line in self.generate_new_revision_summary(\n+                        len(new_commits), new_commits_list, push):\n                     yield line\n             else:\n                 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):\n                     yield line\n+                for line in self.generate_revision_change_graph(push):\n+                    yield line\n \n             # The diffstat is shown from the old revision to the new\n             # revision.  This is to show the truth of what happened in\n@@ -1089,11 +1253,11 @@ class ReferenceChange(Change):\n             yield '\\n'\n             yield 'Summary of changes:\\n'\n             for line in read_git_lines(\n-                ['diff-tree']\n-                + self.diffopts\n-                + ['%s..%s' % (self.old.commit_sha1, self.new.commit_sha1,)],\n-                keepends=True,\n-                ):\n+                    ['diff-tree']\n+                    + self.diffopts\n+                    + ['%s..%s' % (self.old.commit_sha1, self.new.commit_sha1,)],\n+                    keepends=True,\n+                    ):\n                 yield line\n \n         elif self.old.commit_sha1 and not self.new.commit_sha1:\n@@ -1103,7 +1267,7 @@ class ReferenceChange(Change):\n             sha1s = list(push.get_discarded_commits(self))\n             tot = len(sha1s)\n             discarded_revisions = [\n-                Revision(self, GitObject(sha1), num=i+1, tot=tot)\n+                Revision(self, GitObject(sha1), num=i + 1, tot=tot)\n                 for (i, sha1) in enumerate(sha1s)\n                 ]\n \n@@ -1116,6 +1280,8 @@ class ReferenceChange(Change):\n                     yield r.expand(\n                         BRIEF_SUMMARY_TEMPLATE, action='discards', text=subject,\n                         )\n+                for line in self.generate_revision_change_graph(push):\n+                    yield line\n             else:\n                 for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE):\n                     yield line\n@@ -1161,6 +1327,150 @@ class BranchChange(ReferenceChange):\n             old=old, new=new, rev=rev,\n             )\n         self.recipients = environment.get_refchange_recipients(self)\n+        self._single_revision = None\n+\n+    def send_single_combined_email(self, known_added_sha1s):\n+        if not self.environment.combine_when_single_commit:\n+            return None\n+\n+        # In the sadly-all-too-frequent usecase of people pushing only\n+        # one of their commits at a time to a repository, users feel\n+        # the reference change summary emails are noise rather than\n+        # important signal.  This is because, in this particular\n+        # usecase, there is a reference change summary email for each\n+        # new commit, and all these summaries do is point out that\n+        # there is one new commit (which can readily be inferred by\n+        # the existence of the individual revision email that is also\n+        # sent).  In such cases, our users prefer there to be a combined\n+        # reference change summary/new revision email.\n+        #\n+        # So, if the change is an update and it doesn't discard any\n+        # commits, and it adds exactly one non-merge commit (gerrit\n+        # forces a workflow where every commit is individually merged\n+        # and the git-multimail hook fired off for just this one\n+        # change), then we send a combined refchange/revision email.\n+        try:\n+            # If this change is a reference update that doesn't discard\n+            # any commits...\n+            if self.change_type != 'update':\n+                return None\n+\n+            if read_git_lines(\n+                    ['merge-base', self.old.sha1, self.new.sha1]\n+                    ) != [self.old.sha1]:\n+                return None\n+\n+            # Check if this update introduced exactly one non-merge\n+            # commit:\n+\n+            def split_line(line):\n+                \"\"\"Split line into (sha1, [parent,...]).\"\"\"\n+\n+                words = line.split()\n+                return (words[0], words[1:])\n+\n+            # Get the new commits introduced by the push as a list of\n+            # (sha1, [parent,...])\n+            new_commits = [\n+                split_line(line)\n+                for line in read_git_lines(\n+                    [\n+                        'log', '-3', '--format=%H %P',\n+                        '%s..%s' % (self.old.sha1, self.new.sha1),\n+                        ]\n+                    )\n+                ]\n+\n+            if not new_commits:\n+                return None\n+\n+            # If the newest commit is a merge, save it for a later check\n+            # but otherwise ignore it\n+            merge = None\n+            tot = len(new_commits)\n+            if len(new_commits[0][1]) > 1:\n+                merge = new_commits[0][0]\n+                del new_commits[0]\n+\n+            # Our primary check: we can't combine if more than one commit\n+            # is introduced.  We also currently only combine if the new\n+            # commit is a non-merge commit, though it may make sense to\n+            # combine if it is a merge as well.\n+            if not (\n+                    len(new_commits) == 1\n+                    and len(new_commits[0][1]) == 1\n+                    and new_commits[0][0] in known_added_sha1s\n+                    ):\n+                return None\n+\n+            # We do not want to combine revision and refchange emails if\n+            # those go to separate locations.\n+            rev = Revision(self, GitObject(new_commits[0][0]), 1, tot)\n+            if rev.recipients != self.recipients:\n+                return None\n+\n+            # We ignored the newest commit if it was just a merge of the one\n+            # commit being introduced.  But we don't want to ignore that\n+            # merge commit it it involved conflict resolutions.  Check that.\n+            if merge and merge != read_git_output(['diff-tree', '--cc', merge]):\n+                return None\n+\n+            # We can combine the refchange and one new revision emails\n+            # into one.  Return the Revision that a combined email should\n+            # be sent about.\n+            return rev\n+        except CommandError:\n+            # Cannot determine number of commits in old..new or new..old;\n+            # don't combine reference/revision emails:\n+            return None\n+\n+    def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):\n+        values = revision.get_values()\n+        if extra_header_values:\n+            values.update(extra_header_values)\n+        if 'subject' not in extra_header_values:\n+            values['subject'] = self.expand(COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE, **values)\n+\n+        self._single_revision = revision\n+        self.header_template = COMBINED_HEADER_TEMPLATE\n+        self.intro_template = COMBINED_INTRO_TEMPLATE\n+        self.footer_template = COMBINED_FOOTER_TEMPLATE\n+        for line in self.generate_email(push, body_filter, values):\n+            yield line\n+\n+    def generate_email_body(self, push):\n+        '''Call the appropriate body generation routine.\n+\n+        If this is a combined refchange/revision email, the special logic\n+        for handling this combined email comes from this function.  For\n+        other cases, we just use the normal handling.'''\n+\n+        # If self._single_revision isn't set; don't override\n+        if not self._single_revision:\n+            for line in super(BranchChange, self).generate_email_body(push):\n+                yield line\n+            return\n+\n+        # This is a combined refchange/revision email; we first provide\n+        # some info from the refchange portion, and then call the revision\n+        # generate_email_body function to handle the revision portion.\n+        adds = list(generate_summaries(\n+            '--topo-order', '--reverse', '%s..%s'\n+            % (self.old.commit_sha1, self.new.commit_sha1,)\n+            ))\n+\n+        yield self.expand(\"The following commit(s) were added to %(refname)s by this push:\\n\")\n+        for (sha1, subject) in adds:\n+            yield self.expand(\n+                BRIEF_SUMMARY_TEMPLATE, action='new',\n+                rev_short=sha1, text=subject,\n+                )\n+\n+        yield self._single_revision.rev.short + \" is described below\\n\"\n+        yield '\\n'\n+\n+        for line in self._single_revision.generate_email_body(push):\n+            yield line\n \n \n class AnnotatedTagChange(ReferenceChange):\n@@ -1390,13 +1700,17 @@ class SendMailer(Mailer):\n             sys.exit(1)\n         try:\n             p.stdin.writelines(lines)\n-        except:\n+        except Exception, e:\n             sys.stderr.write(\n                 '*** Error while generating commit email\\n'\n                 '***  - mail sending aborted.\\n'\n                 )\n-            p.terminate()\n-            raise\n+            try:\n+                # subprocess.terminate() is not available in Python 2.4\n+                p.terminate()\n+            except AttributeError:\n+                pass\n+            raise e\n         else:\n             p.stdin.close()\n             retcode = p.wait()\n@@ -1407,34 +1721,72 @@ class SendMailer(Mailer):\n class SMTPMailer(Mailer):\n     \"\"\"Send emails using Python's smtplib.\"\"\"\n \n-    def __init__(self, envelopesender, smtpserver):\n+    def __init__(self, envelopesender, smtpserver,\n+                 smtpservertimeout=10.0, smtpserverdebuglevel=0,\n+                 smtpencryption='none',\n+                 smtpuser='', smtppass='',\n+                 ):\n         if not envelopesender:\n             sys.stderr.write(\n                 'fatal: git_multimail: cannot use SMTPMailer without a sender address.\\n'\n                 'please set either multimailhook.envelopeSender or user.email\\n'\n                 )\n             sys.exit(1)\n+        if smtpencryption == 'ssl' and not (smtpuser and smtppass):\n+            raise ConfigurationException(\n+                'Cannot use SMTPMailer with security option ssl '\n+                'without options username and password.'\n+                )\n         self.envelopesender = envelopesender\n         self.smtpserver = smtpserver\n+        self.smtpservertimeout = smtpservertimeout\n+        self.smtpserverdebuglevel = smtpserverdebuglevel\n+        self.security = smtpencryption\n+        self.username = smtpuser\n+        self.password = smtppass\n         try:\n-            self.smtp = smtplib.SMTP(self.smtpserver)\n+            if self.security == 'none':\n+                self.smtp = smtplib.SMTP(self.smtpserver, timeout=self.smtpservertimeout)\n+            elif self.security == 'ssl':\n+                self.smtp = smtplib.SMTP_SSL(self.smtpserver, timeout=self.smtpservertimeout)\n+            elif self.security == 'tls':\n+                if ':' not in self.smtpserver:\n+                    self.smtpserver += ':587'  # default port for TLS\n+                self.smtp = smtplib.SMTP(self.smtpserver, timeout=self.smtpservertimeout)\n+                self.smtp.ehlo()\n+                self.smtp.starttls()\n+                self.smtp.ehlo()\n+            else:\n+                sys.stdout.write('*** Error: Control reached an invalid option. ***')\n+                sys.exit(1)\n+            if self.smtpserverdebuglevel > 0:\n+                sys.stdout.write(\n+                    \"*** Setting debug on for SMTP server connection (%s) ***\\n\"\n+                    % self.smtpserverdebuglevel)\n+                self.smtp.set_debuglevel(self.smtpserverdebuglevel)\n         except Exception, e:\n-            sys.stderr.write('*** Error establishing SMTP connection to %s***\\n' % self.smtpserver)\n+            sys.stderr.write(\n+                '*** Error establishing SMTP connection to %s ***\\n'\n+                % self.smtpserver)\n             sys.stderr.write('*** %s\\n' % str(e))\n             sys.exit(1)\n \n     def __del__(self):\n-        self.smtp.quit()\n+        if hasattr(self, 'smtp'):\n+            self.smtp.quit()\n \n     def send(self, lines, to_addrs):\n         try:\n+            if self.username or self.password:\n+                sys.stderr.write(\"*** Authenticating as %s ***\\n\" % self.username)\n+                self.smtp.login(self.username, self.password)\n             msg = ''.join(lines)\n             # turn comma-separated list into Python list if needed.\n             if isinstance(to_addrs, basestring):\n                 to_addrs = [email for (name, email) in getaddresses([to_addrs])]\n             self.smtp.sendmail(self.envelopesender, to_addrs, msg)\n         except Exception, e:\n-            sys.stderr.write('*** Error sending email***\\n')\n+            sys.stderr.write('*** Error sending email ***\\n')\n             sys.stderr.write('*** %s\\n' % str(e))\n             self.smtp.quit()\n             sys.exit(1)\n@@ -1549,6 +1901,10 @@ class Environment(object):\n \n             True iff announce emails should include a shortlog.\n \n+        refchange_showgraph (bool)\n+\n+            True iff refchanges emails should include a detailed graph.\n+\n         refchange_showlog (bool)\n \n             True iff refchanges emails should include a detailed log.\n@@ -1559,6 +1915,12 @@ class Environment(object):\n             summary email.  The value should be a list of strings\n             representing words to be passed to the command.\n \n+        graphopts (list of strings)\n+\n+            Analogous to diffopts, but contains options passed to\n+            'git log --graph' when generating the detailed graph for\n+            a set of commits (see refchange_showgraph)\n+\n         logopts (list of strings)\n \n             Analogous to diffopts, but contains options passed to\n@@ -1571,6 +1933,17 @@ class Environment(object):\n             commit mail.  The value should be a list of strings\n             representing words to be passed to the command.\n \n+        quiet (bool)\n+            On success do not write to stderr\n+\n+        stdout (bool)\n+            Write email to stdout rather than emailing. Useful for debugging\n+\n+        combine_when_single_commit (bool)\n+\n+            True if a combined email should be produced when a single\n+            new commit is pushed to a branch, False otherwise.\n+\n     \"\"\"\n \n     REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\\.git)$')\n@@ -1580,9 +1953,14 @@ class Environment(object):\n         self.announce_show_shortlog = False\n         self.maxcommitemails = 500\n         self.diffopts = ['--stat', '--summary', '--find-copies-harder']\n+        self.graphopts = ['--oneline', '--decorate']\n         self.logopts = []\n+        self.refchange_showgraph = False\n         self.refchange_showlog = False\n         self.commitlogopts = ['-C', '--stat', '-p', '--cc']\n+        self.quiet = False\n+        self.stdout = False\n+        self.combine_when_single_commit = True\n \n         self.COMPUTED_KEYS = [\n             'administrator',\n@@ -1614,6 +1992,14 @@ class Environment(object):\n     def get_pusher_email(self):\n         return None\n \n+    def get_fromaddr(self):\n+        config = Config('user')\n+        fromname = config.get('name', default='')\n+        fromemail = config.get('email', default='')\n+        if fromemail:\n+            return formataddr([fromname, fromemail])\n+        return self.get_sender()\n+\n     def get_administrator(self):\n         return 'the administrator of this repository'\n \n@@ -1631,7 +2017,7 @@ class Environment(object):\n         return CHARSET\n \n     def get_values(self):\n-        \"\"\"Return a dictionary {keyword : expansion} for this Environment.\n+        \"\"\"Return a dictionary {keyword: expansion} for this Environment.\n \n         This method is called by Change._compute_values().  The keys\n         in the returned dictionary are available to be used in any of\n@@ -1699,6 +2085,24 @@ class Environment(object):\n \n         return lines\n \n+    def log_msg(self, msg):\n+        \"\"\"Write the string msg on a log file or on stderr.\n+\n+        Sends the text to stderr by default, override to change the behavior.\"\"\"\n+        sys.stderr.write(msg)\n+\n+    def log_warning(self, msg):\n+        \"\"\"Write the string msg on a log file or on stderr.\n+\n+        Sends the text to stderr by default, override to change the behavior.\"\"\"\n+        sys.stderr.write(msg)\n+\n+    def log_error(self, msg):\n+        \"\"\"Write the string msg on a log file or on stderr.\n+\n+        Sends the text to stderr by default, override to change the behavior.\"\"\"\n+        sys.stderr.write(msg)\n+\n \n class ConfigEnvironmentMixin(Environment):\n     \"\"\"A mixin that sets self.config to its constructor's config argument.\n@@ -1723,20 +2127,23 @@ class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):\n             config=config, **kw\n             )\n \n-        self.announce_show_shortlog = config.get_bool(\n-            'announceshortlog', default=self.announce_show_shortlog\n-            )\n-\n-        self.refchange_showlog = config.get_bool(\n-            'refchangeshowlog', default=self.refchange_showlog\n-            )\n+        for var, cfg in (\n+                ('announce_show_shortlog', 'announceshortlog'),\n+                ('refchange_showgraph', 'refchangeShowGraph'),\n+                ('refchange_showlog', 'refchangeshowlog'),\n+                ('quiet', 'quiet'),\n+                ('stdout', 'stdout'),\n+                ):\n+            val = config.get_bool(cfg)\n+            if val is not None:\n+                setattr(self, var, val)\n \n         maxcommitemails = config.get('maxcommitemails')\n         if maxcommitemails is not None:\n             try:\n                 self.maxcommitemails = int(maxcommitemails)\n             except ValueError:\n-                sys.stderr.write(\n+                self.log_warning(\n                     '*** Malformed value for multimailhook.maxCommitEmails: %s\\n' % maxcommitemails\n                     + '*** Expected a number.  Ignoring.\\n'\n                     )\n@@ -1745,6 +2152,10 @@ class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):\n         if diffopts is not None:\n             self.diffopts = shlex.split(diffopts)\n \n+        graphopts = config.get('graphOpts')\n+        if graphopts is not None:\n+            self.graphopts = shlex.split(graphopts)\n+\n         logopts = config.get('logopts')\n         if logopts is not None:\n             self.logopts = shlex.split(logopts)\n@@ -1756,14 +2167,18 @@ class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):\n         reply_to = config.get('replyTo')\n         self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to)\n         if (\n-            self.__reply_to_refchange is not None\n-            and self.__reply_to_refchange.lower() == 'author'\n-            ):\n+                self.__reply_to_refchange is not None\n+                and self.__reply_to_refchange.lower() == 'author'\n+                ):\n             raise ConfigurationException(\n                 '\"author\" is not an allowed setting for replyToRefchange'\n                 )\n         self.__reply_to_commit = config.get('replyToCommit', default=reply_to)\n \n+        combine = config.get_bool('combineWhenSingleCommit')\n+        if combine is not None:\n+            self.combine_when_single_commit = combine\n+\n     def get_administrator(self):\n         return (\n             self.config.get('administrator')\n@@ -1779,8 +2194,12 @@ class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):\n \n     def get_emailprefix(self):\n         emailprefix = self.config.get('emailprefix')\n-        if emailprefix and emailprefix.strip():\n-            return emailprefix.strip() + ' '\n+        if emailprefix is not None:\n+            emailprefix = emailprefix.strip()\n+            if emailprefix:\n+                return emailprefix + ' '\n+            else:\n+                return ''\n         else:\n             return '[%s] ' % (self.get_repo_shortname(),)\n \n@@ -1791,14 +2210,7 @@ class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):\n         fromaddr = self.config.get('from')\n         if fromaddr:\n             return fromaddr\n-        else:\n-            config = Config('user')\n-            fromname = config.get('name', default='')\n-            fromemail = config.get('email', default='')\n-            if fromemail:\n-                return formataddr([fromname, fromemail])\n-            else:\n-                return self.get_sender()\n+        return super(ConfigOptionsEnvironmentMixin, self).get_fromaddr()\n \n     def get_reply_to_refchange(self, refchange):\n         if self.__reply_to_refchange is None:\n@@ -1814,7 +2226,7 @@ class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):\n         if self.__reply_to_commit is None:\n             return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_commit(revision)\n         elif self.__reply_to_commit.lower() == 'author':\n-            return revision.get_author()\n+            return revision.author\n         elif self.__reply_to_commit.lower() == 'pusher':\n             return self.get_pusher_email()\n         elif self.__reply_to_commit.lower() == 'none':\n@@ -1822,6 +2234,9 @@ class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):\n         else:\n             return self.__reply_to_commit\n \n+    def get_scancommitforcc(self):\n+        return self.config.get('scancommitforcc')\n+\n \n class FilterLinesEnvironmentMixin(Environment):\n     \"\"\"Handle encoding and maximum line length of body lines.\n@@ -1862,9 +2277,9 @@ class FilterLinesEnvironmentMixin(Environment):\n \n \n class ConfigFilterLinesEnvironmentMixin(\n-    ConfigEnvironmentMixin,\n-    FilterLinesEnvironmentMixin,\n-    ):\n+        ConfigEnvironmentMixin,\n+        FilterLinesEnvironmentMixin,\n+        ):\n     \"\"\"Handle encoding and maximum line length based on config.\"\"\"\n \n     def __init__(self, config, **kw):\n@@ -1896,9 +2311,9 @@ class MaxlinesEnvironmentMixin(Environment):\n \n \n class ConfigMaxlinesEnvironmentMixin(\n-    ConfigEnvironmentMixin,\n-    MaxlinesEnvironmentMixin,\n-    ):\n+        ConfigEnvironmentMixin,\n+        MaxlinesEnvironmentMixin,\n+        ):\n     \"\"\"Limit the email body to the number of lines specified in config.\"\"\"\n \n     def __init__(self, config, **kw):\n@@ -1927,9 +2342,9 @@ class FQDNEnvironmentMixin(Environment):\n \n \n class ConfigFQDNEnvironmentMixin(\n-    ConfigEnvironmentMixin,\n-    FQDNEnvironmentMixin,\n-    ):\n+        ConfigEnvironmentMixin,\n+        FQDNEnvironmentMixin,\n+        ):\n     \"\"\"Read the FQDN from the config.\"\"\"\n \n     def __init__(self, config, **kw):\n@@ -1970,10 +2385,10 @@ class StaticRecipientsEnvironmentMixin(Environment):\n     \"\"\"Set recipients statically based on constructor parameters.\"\"\"\n \n     def __init__(\n-        self,\n-        refchange_recipients, announce_recipients, revision_recipients,\n-        **kw\n-        ):\n+            self,\n+            refchange_recipients, announce_recipients, revision_recipients, scancommitforcc,\n+            **kw\n+            ):\n         super(StaticRecipientsEnvironmentMixin, self).__init__(**kw)\n \n         # The recipients for various types of notification emails, as\n@@ -1985,7 +2400,8 @@ class StaticRecipientsEnvironmentMixin(Environment):\n         # compute them once and for all:\n         if not (refchange_recipients\n                 or announce_recipients\n-                or revision_recipients):\n+                or revision_recipients\n+                or scancommitforcc):\n             raise ConfigurationException('No email recipients configured!')\n         self.__refchange_recipients = refchange_recipients\n         self.__announce_recipients = announce_recipients\n@@ -2002,9 +2418,9 @@ class StaticRecipientsEnvironmentMixin(Environment):\n \n \n class ConfigRecipientsEnvironmentMixin(\n-    ConfigEnvironmentMixin,\n-    StaticRecipientsEnvironmentMixin\n-    ):\n+        ConfigEnvironmentMixin,\n+        StaticRecipientsEnvironmentMixin\n+        ):\n     \"\"\"Determine recipients statically based on config.\"\"\"\n \n     def __init__(self, config, **kw):\n@@ -2019,6 +2435,7 @@ class ConfigRecipientsEnvironmentMixin(\n             revision_recipients=self._get_recipients(\n                 config, 'commitlist', 'mailinglist',\n                 ),\n+            scancommitforcc=config.get('scancommitforcc'),\n             **kw\n             )\n \n@@ -2067,20 +2484,20 @@ class ProjectdescEnvironmentMixin(Environment):\n \n class GenericEnvironmentMixin(Environment):\n     def get_pusher(self):\n-        return self.osenv.get('USER', 'unknown user')\n+        return self.osenv.get('USER', self.osenv.get('USERNAME', 'unknown user'))\n \n \n class GenericEnvironment(\n-    ProjectdescEnvironmentMixin,\n-    ConfigMaxlinesEnvironmentMixin,\n-    ComputeFQDNEnvironmentMixin,\n-    ConfigFilterLinesEnvironmentMixin,\n-    ConfigRecipientsEnvironmentMixin,\n-    PusherDomainEnvironmentMixin,\n-    ConfigOptionsEnvironmentMixin,\n-    GenericEnvironmentMixin,\n-    Environment,\n-    ):\n+        ProjectdescEnvironmentMixin,\n+        ConfigMaxlinesEnvironmentMixin,\n+        ComputeFQDNEnvironmentMixin,\n+        ConfigFilterLinesEnvironmentMixin,\n+        ConfigRecipientsEnvironmentMixin,\n+        PusherDomainEnvironmentMixin,\n+        ConfigOptionsEnvironmentMixin,\n+        GenericEnvironmentMixin,\n+        Environment,\n+        ):\n     pass\n \n \n@@ -2097,6 +2514,45 @@ class GitoliteEnvironmentMixin(Environment):\n     def get_pusher(self):\n         return self.osenv.get('GL_USER', 'unknown user')\n \n+    def get_fromaddr(self):\n+        GL_USER = self.osenv.get('GL_USER')\n+        if GL_USER is not None:\n+            # Find the path to gitolite.conf.  Note that gitolite v3\n+            # did away with the GL_ADMINDIR and GL_CONF environment\n+            # variables (they are now hard-coded).\n+            GL_ADMINDIR = self.osenv.get(\n+                'GL_ADMINDIR',\n+                os.path.expanduser(os.path.join('~', '.gitolite')))\n+            GL_CONF = self.osenv.get(\n+                'GL_CONF',\n+                os.path.join(GL_ADMINDIR, 'conf', 'gitolite.conf'))\n+            if os.path.isfile(GL_CONF):\n+                f = open(GL_CONF, 'rU')\n+                try:\n+                    in_user_emails_section = False\n+                    re_template = r'^\\s*#\\s*{}\\s*$'\n+                    re_begin, re_user, re_end = (\n+                        re.compile(re_template.format(x))\n+                        for x in (\n+                            r'BEGIN\\s+USER\\s+EMAILS',\n+                            re.escape(GL_USER) + r'\\s+(.*)',\n+                            r'END\\s+USER\\s+EMAILS',\n+                            ))\n+                    for l in f:\n+                        l = l.rstrip('\\n')\n+                        if not in_user_emails_section:\n+                            if re_begin.match(l):\n+                                in_user_emails_section = True\n+                            continue\n+                        if re_end.match(l):\n+                            break\n+                        m = re_user.match(l)\n+                        if m:\n+                            return m.group(1)\n+                finally:\n+                    f.close()\n+        return super(GitoliteEnvironmentMixin, self).get_fromaddr()\n+\n \n class IncrementalDateTime(object):\n     \"\"\"Simple wrapper to give incremental date/times.\n@@ -2116,16 +2572,16 @@ class IncrementalDateTime(object):\n \n \n class GitoliteEnvironment(\n-    ProjectdescEnvironmentMixin,\n-    ConfigMaxlinesEnvironmentMixin,\n-    ComputeFQDNEnvironmentMixin,\n-    ConfigFilterLinesEnvironmentMixin,\n-    ConfigRecipientsEnvironmentMixin,\n-    PusherDomainEnvironmentMixin,\n-    ConfigOptionsEnvironmentMixin,\n-    GitoliteEnvironmentMixin,\n-    Environment,\n-    ):\n+        ProjectdescEnvironmentMixin,\n+        ConfigMaxlinesEnvironmentMixin,\n+        ComputeFQDNEnvironmentMixin,\n+        ConfigFilterLinesEnvironmentMixin,\n+        ConfigRecipientsEnvironmentMixin,\n+        PusherDomainEnvironmentMixin,\n+        ConfigOptionsEnvironmentMixin,\n+        GitoliteEnvironmentMixin,\n+        Environment,\n+        ):\n     pass\n \n \n@@ -2149,9 +2605,9 @@ class Push(object):\n     references.\n \n     The first step is to determine the \"other\" references--those\n-    unaffected by the current push.  They are computed by\n-    Push._compute_other_ref_sha1s() by listing all references then\n-    removing any affected by this push.\n+    unaffected by the current push.  They are computed by listing all\n+    references then removing any affected by this push.  The results\n+    are stored in Push._other_ref_sha1s.\n \n     The commits contained in the repository before this push were\n \n@@ -2187,7 +2643,7 @@ class Push(object):\n     possible and working with SHA1s thereafter (because SHA1s are\n     immutable).\"\"\"\n \n-    # A map {(changeclass, changetype) : integer} specifying the order\n+    # A map {(changeclass, changetype): integer} specifying the order\n     # that reference changes will be processed if multiple reference\n     # changes are included in a single push.  The order is significant\n     # mostly because new commit notifications are threaded together\n@@ -2211,66 +2667,134 @@ class Push(object):\n             ])\n         )\n \n-    def __init__(self, changes):\n+    def __init__(self, changes, ignore_other_refs=False):\n         self.changes = sorted(changes, key=self._sort_key)\n+        self.__other_ref_sha1s = None\n+        self.__cached_commits_spec = {}\n \n-        # The SHA-1s of commits referred to by references unaffected\n-        # by this push:\n-        other_ref_sha1s = self._compute_other_ref_sha1s()\n+        if ignore_other_refs:\n+            self.__other_ref_sha1s = set()\n \n-        self._old_rev_exclusion_spec = self._compute_rev_exclusion_spec(\n-            other_ref_sha1s.union(\n-                change.old.sha1\n+    @classmethod\n+    def _sort_key(klass, change):\n+        return (klass.SORT_ORDER[change.__class__, change.change_type], change.refname,)\n+\n+    @property\n+    def _other_ref_sha1s(self):\n+        \"\"\"The GitObjects referred to by references unaffected by this push.\n+        \"\"\"\n+        if self.__other_ref_sha1s is None:\n+            # The refnames being changed by this push:\n+            updated_refs = set(\n+                change.refname\n                 for change in self.changes\n-                if change.old.type in ['commit', 'tag']\n                 )\n-            )\n-        self._new_rev_exclusion_spec = self._compute_rev_exclusion_spec(\n-            other_ref_sha1s.union(\n-                change.new.sha1\n-                for change in self.changes\n-                if change.new.type in ['commit', 'tag']\n+\n+            # The SHA-1s of commits referred to by all references in this\n+            # repository *except* updated_refs:\n+            sha1s = set()\n+            fmt = (\n+                '%(objectname) %(objecttype) %(refname)\\n'\n+                '%(*objectname) %(*objecttype) %(refname)'\n                 )\n-            )\n+            for line in read_git_lines(\n+                    ['for-each-ref', '--format=%s' % (fmt,)]):\n+                (sha1, type, name) = line.split(' ', 2)\n+                if sha1 and type == 'commit' and name not in updated_refs:\n+                    sha1s.add(sha1)\n \n-    @classmethod\n-    def _sort_key(klass, change):\n-        return (klass.SORT_ORDER[change.__class__, change.change_type], change.refname,)\n+            self.__other_ref_sha1s = sha1s\n+\n+        return self.__other_ref_sha1s\n+\n+    def _get_commits_spec_incl(self, new_or_old, reference_change=None):\n+        \"\"\"Get new or old SHA-1 from one or each of the changed refs.\n \n-    def _compute_other_ref_sha1s(self):\n-        \"\"\"Return the GitObjects referred to by references unaffected by this push.\"\"\"\n+        Return a list of SHA-1 commit identifier strings suitable as\n+        arguments to 'git rev-list' (or 'git log' or ...).  The\n+        returned identifiers are either the old or new values from one\n+        or all of the changed references, depending on the values of\n+        new_or_old and reference_change.\n \n-        # The refnames being changed by this push:\n-        updated_refs = set(\n-            change.refname\n+        new_or_old is either the string 'new' or the string 'old'.  If\n+        'new', the returned SHA-1 identifiers are the new values from\n+        each changed reference.  If 'old', the SHA-1 identifiers are\n+        the old values from each changed reference.\n+\n+        If reference_change is specified and not None, only the new or\n+        old reference from the specified reference is included in the\n+        return value.\n+\n+        This function returns None if there are no matching revisions\n+        (e.g., because a branch was deleted and new_or_old is 'new').\n+        \"\"\"\n+\n+        if not reference_change:\n+            incl_spec = sorted(\n+                getattr(change, new_or_old).sha1\n+                for change in self.changes\n+                if getattr(change, new_or_old)\n+                )\n+            if not incl_spec:\n+                incl_spec = None\n+        elif not getattr(reference_change, new_or_old).commit_sha1:\n+            incl_spec = None\n+        else:\n+            incl_spec = [getattr(reference_change, new_or_old).commit_sha1]\n+        return incl_spec\n+\n+    def _get_commits_spec_excl(self, new_or_old):\n+        \"\"\"Get exclusion revisions for determining new or discarded commits.\n+\n+        Return a list of strings suitable as arguments to 'git\n+        rev-list' (or 'git log' or ...) that will exclude all\n+        commits that, depending on the value of new_or_old, were\n+        either previously in the repository (useful for determining\n+        which commits are new to the repository) or currently in the\n+        repository (useful for determining which commits were\n+        discarded from the repository).\n+\n+        new_or_old is either the string 'new' or the string 'old'.  If\n+        'new', the commits to be excluded are those that were in the\n+        repository before the push.  If 'old', the commits to be\n+        excluded are those that are currently in the repository.  \"\"\"\n+\n+        old_or_new = {'old': 'new', 'new': 'old'}[new_or_old]\n+        excl_revs = self._other_ref_sha1s.union(\n+            getattr(change, old_or_new).sha1\n             for change in self.changes\n+            if getattr(change, old_or_new).type in ['commit', 'tag']\n             )\n+        return ['^' + sha1 for sha1 in sorted(excl_revs)]\n \n-        # The SHA-1s of commits referred to by all references in this\n-        # repository *except* updated_refs:\n-        sha1s = set()\n-        fmt = (\n-            '%(objectname) %(objecttype) %(refname)\\n'\n-            '%(*objectname) %(*objecttype) %(refname)'\n-            )\n-        for line in read_git_lines(['for-each-ref', '--format=%s' % (fmt,)]):\n-            (sha1, type, name) = line.split(' ', 2)\n-            if sha1 and type == 'commit' and name not in updated_refs:\n-                sha1s.add(sha1)\n+    def get_commits_spec(self, new_or_old, reference_change=None):\n+        \"\"\"Get rev-list arguments for added or discarded commits.\n \n-        return sha1s\n+        Return a list of strings suitable as arguments to 'git\n+        rev-list' (or 'git log' or ...) that select those commits\n+        that, depending on the value of new_or_old, are either new to\n+        the repository or were discarded from the repository.\n \n-    def _compute_rev_exclusion_spec(self, sha1s):\n-        \"\"\"Return an exclusion specification for 'git rev-list'.\n+        new_or_old is either the string 'new' or the string 'old'.  If\n+        'new', the returned list is used to select commits that are\n+        new to the repository.  If 'old', the returned value is used\n+        to select the commits that have been discarded from the\n+        repository.\n \n-        git_objects is an iterable over GitObject instances.  Return a\n-        string that can be passed to the standard input of 'git\n-        rev-list --stdin' to exclude all of the commits referred to by\n-        git_objects.\"\"\"\n+        If reference_change is specified and not None, the new or\n+        discarded commits are limited to those that are reachable from\n+        the new or old value of the specified reference.\n \n-        return ''.join(\n-            ['^%s\\n' % (sha1,) for sha1 in sorted(sha1s)]\n-            )\n+        This function returns None if there are no added (or discarded)\n+        revisions.\n+        \"\"\"\n+        key = (new_or_old, reference_change)\n+        if key not in self.__cached_commits_spec:\n+            ret = self._get_commits_spec_incl(new_or_old, reference_change)\n+            if ret is not None:\n+                ret.extend(self._get_commits_spec_excl(new_or_old))\n+            self.__cached_commits_spec[key] = ret\n+        return self.__cached_commits_spec[key]\n \n     def get_new_commits(self, reference_change=None):\n         \"\"\"Return a list of commits added by this push.\n@@ -2280,19 +2804,8 @@ class Push(object):\n         reference_change is None, then return a list of *all* commits\n         added by this push.\"\"\"\n \n-        if not reference_change:\n-            new_revs = sorted(\n-                change.new.sha1\n-                for change in self.changes\n-                if change.new\n-                )\n-        elif not reference_change.new.commit_sha1:\n-            return []\n-        else:\n-            new_revs = [reference_change.new.commit_sha1]\n-\n-        cmd = ['rev-list', '--stdin'] + new_revs\n-        return read_git_lines(cmd, input=self._old_rev_exclusion_spec)\n+        spec = self.get_commits_spec('new', reference_change)\n+        return git_rev_list(spec)\n \n     def get_discarded_commits(self, reference_change):\n         \"\"\"Return a list of commits discarded by this push.\n@@ -2301,13 +2814,8 @@ class Push(object):\n         entirely discarded from the repository by the part of this\n         push represented by reference_change.\"\"\"\n \n-        if not reference_change.old.commit_sha1:\n-            return []\n-        else:\n-            old_revs = [reference_change.old.commit_sha1]\n-\n-        cmd = ['rev-list', '--stdin'] + old_revs\n-        return read_git_lines(cmd, input=self._new_rev_exclusion_spec)\n+        spec = self.get_commits_spec('old', reference_change)\n+        return git_rev_list(spec)\n \n     def send_emails(self, mailer, body_filter=None):\n         \"\"\"Use send all of the notification emails needed for this push.\n@@ -2325,30 +2833,43 @@ class Push(object):\n         unhandled_sha1s = set(self.get_new_commits())\n         send_date = IncrementalDateTime()\n         for change in self.changes:\n+            sha1s = []\n+            for sha1 in reversed(list(self.get_new_commits(change))):\n+                if sha1 in unhandled_sha1s:\n+                    sha1s.append(sha1)\n+                    unhandled_sha1s.remove(sha1)\n+\n             # Check if we've got anyone to send to\n             if not change.recipients:\n-                sys.stderr.write(\n+                change.environment.log_warning(\n                     '*** no recipients configured so no email will be sent\\n'\n                     '*** for %r update %s->%s\\n'\n                     % (change.refname, change.old.sha1, change.new.sha1,)\n                     )\n             else:\n-                sys.stderr.write('Sending notification emails to: %s\\n' % (change.recipients,))\n-                extra_values = {'send_date' : send_date.next()}\n-                mailer.send(\n-                    change.generate_email(self, body_filter, extra_values),\n-                    change.recipients,\n-                    )\n+                if not change.environment.quiet:\n+                    change.environment.log_msg(\n+                        'Sending notification emails to: %s\\n' % (change.recipients,))\n+                extra_values = {'send_date': send_date.next()}\n \n-            sha1s = []\n-            for sha1 in reversed(list(self.get_new_commits(change))):\n-                if sha1 in unhandled_sha1s:\n-                    sha1s.append(sha1)\n-                    unhandled_sha1s.remove(sha1)\n+                rev = change.send_single_combined_email(sha1s)\n+                if rev:\n+                    mailer.send(\n+                        change.generate_combined_email(self, rev, body_filter, extra_values),\n+                        rev.recipients,\n+                        )\n+                    # This change is now fully handled; no need to handle\n+                    # individual revisions any further.\n+                    continue\n+                else:\n+                    mailer.send(\n+                        change.generate_email(self, body_filter, extra_values),\n+                        change.recipients,\n+                        )\n \n             max_emails = change.environment.maxcommitemails\n             if max_emails and len(sha1s) > max_emails:\n-                sys.stderr.write(\n+                change.environment.log_warning(\n                     '*** Too many new commits (%d), not sending commit emails.\\n' % len(sha1s)\n                     + '*** Try setting multimailhook.maxCommitEmails to a greater value\\n'\n                     + '*** Currently, multimailhook.maxCommitEmails=%d\\n' % max_emails\n@@ -2356,9 +2877,13 @@ class Push(object):\n                 return\n \n             for (num, sha1) in enumerate(sha1s):\n-                rev = Revision(change, GitObject(sha1), num=num+1, tot=len(sha1s))\n+                rev = Revision(change, GitObject(sha1), num=num + 1, tot=len(sha1s))\n+                if not rev.recipients and rev.cc_recipients:\n+                    change.environment.log_msg('*** Replacing Cc: with To:\\n')\n+                    rev.recipients = rev.cc_recipients\n+                    rev.cc_recipients = None\n                 if rev.recipients:\n-                    extra_values = {'send_date' : send_date.next()}\n+                    extra_values = {'send_date': send_date.next()}\n                     mailer.send(\n                         rev.generate_email(self, body_filter, extra_values),\n                         rev.recipients,\n@@ -2366,7 +2891,7 @@ class Push(object):\n \n         # Consistency check:\n         if unhandled_sha1s:\n-            sys.stderr.write(\n+            change.environment.log_error(\n                 'ERROR: No emails were sent for the following new commits:\\n'\n                 '    %s\\n'\n                 % ('\\n    '.join(sorted(unhandled_sha1s)),)\n@@ -2384,7 +2909,7 @@ def run_as_post_receive_hook(environment, mailer):\n     push.send_emails(mailer, body_filter=environment.filter_body)\n \n \n-def run_as_update_hook(environment, mailer, refname, oldrev, newrev):\n+def run_as_update_hook(environment, mailer, refname, oldrev, newrev, force_send=False):\n     changes = [\n         ReferenceChange.create(\n             environment,\n@@ -2393,7 +2918,7 @@ def run_as_update_hook(environment, mailer, refname, oldrev, newrev):\n             refname,\n             ),\n         ]\n-    push = Push(changes)\n+    push = Push(changes, force_send)\n     push.send_emails(mailer, body_filter=environment.filter_body)\n \n \n@@ -2402,9 +2927,18 @@ def choose_mailer(config, environment):\n \n     if mailer == 'smtp':\n         smtpserver = config.get('smtpserver', default='localhost')\n+        smtpservertimeout = float(config.get('smtpservertimeout', default=10.0))\n+        smtpserverdebuglevel = int(config.get('smtpserverdebuglevel', default=0))\n+        smtpencryption = config.get('smtpencryption', default='none')\n+        smtpuser = config.get('smtpuser', default='')\n+        smtppass = config.get('smtppass', default='')\n         mailer = SMTPMailer(\n             envelopesender=(environment.get_sender() or environment.get_fromaddr()),\n-            smtpserver=smtpserver,\n+            smtpserver=smtpserver, smtpservertimeout=smtpservertimeout,\n+            smtpserverdebuglevel=smtpserverdebuglevel,\n+            smtpencryption=smtpencryption,\n+            smtpuser=smtpuser,\n+            smtppass=smtppass,\n             )\n     elif mailer == 'sendmail':\n         command = config.get('sendmailcommand')\n@@ -2412,7 +2946,7 @@ def choose_mailer(config, environment):\n             command = shlex.split(command)\n         mailer = SendMailer(command=command, envelopesender=environment.get_sender())\n     else:\n-        sys.stderr.write(\n+        environment.log_error(\n             'fatal: multimailhook.mailer is set to an incorrect value: \"%s\"\\n' % mailer\n             + 'please use one of \"smtp\" or \"sendmail\".\\n'\n             )\n@@ -2421,8 +2955,8 @@ def choose_mailer(config, environment):\n \n \n KNOWN_ENVIRONMENTS = {\n-    'generic' : GenericEnvironmentMixin,\n-    'gitolite' : GitoliteEnvironmentMixin,\n+    'generic': GenericEnvironmentMixin,\n+    'gitolite': GitoliteEnvironmentMixin,\n     }\n \n \n@@ -2439,8 +2973,8 @@ def choose_environment(config, osenv=None, env=None, recipients=None):\n         ConfigOptionsEnvironmentMixin,\n         ]\n     environment_kw = {\n-        'osenv' : osenv,\n-        'config' : config,\n+        'osenv': osenv,\n+        'config': config,\n         }\n \n     if not env:\n@@ -2459,6 +2993,7 @@ def choose_environment(config, osenv=None, env=None, recipients=None):\n         environment_kw['refchange_recipients'] = recipients\n         environment_kw['announce_recipients'] = recipients\n         environment_kw['revision_recipients'] = recipients\n+        environment_kw['scancommitforcc'] = config.get('scancommitforcc')\n     else:\n         environment_mixins.insert(0, ConfigRecipientsEnvironmentMixin)\n \n@@ -2499,6 +3034,14 @@ def main(args):\n             '(intended for debugging purposes).'\n             ),\n         )\n+    parser.add_option(\n+        '--force-send', action='store_true', default=False,\n+        help=(\n+            'Force sending refchange email when using as an update hook. '\n+            'This is useful to work around the unreliable new commits '\n+            'detection in this mode.'\n+            ),\n+        )\n \n     (options, args) = parser.parse_args(args)\n \n@@ -2513,11 +3056,11 @@ def main(args):\n \n         if options.show_env:\n             sys.stderr.write('Environment values:\\n')\n-            for (k,v) in sorted(environment.get_values().items()):\n-                sys.stderr.write('    %s : %r\\n' % (k,v))\n+            for (k, v) in sorted(environment.get_values().items()):\n+                sys.stderr.write('    %s : %r\\n' % (k, v))\n             sys.stderr.write('\\n')\n \n-        if options.stdout:\n+        if options.stdout or environment.stdout:\n             mailer = OutputMailer(sys.stdout)\n         else:\n             mailer = choose_mailer(config, environment)\n@@ -2528,7 +3071,7 @@ def main(args):\n             if len(args) != 3:\n                 parser.error('Need zero or three non-option arguments')\n             (refname, oldrev, newrev) = args\n-            run_as_update_hook(environment, mailer, refname, oldrev, newrev)\n+            run_as_update_hook(environment, mailer, refname, oldrev, newrev, options.force_send)\n         else:\n             run_as_post_receive_hook(environment, mailer)\n     except ConfigurationException, e:\ndiff --git a/contrib/hooks/multimail/migrate-mailhook-config b/contrib/hooks/multimail/migrate-mailhook-config\nindex 04eeaac..d0e9b39 100755\n--- a/contrib/hooks/multimail/migrate-mailhook-config\n+++ b/contrib/hooks/multimail/migrate-mailhook-config\n@@ -22,6 +22,7 @@ OLD_NAMES = [\n     'showrev',\n     'emailmaxlines',\n     'diffopts',\n+    'scancommitforcc',\n     ]\n \n NEW_NAMES = [\n@@ -38,6 +39,7 @@ NEW_NAMES = [\n     'emailmaxlines',\n     'diffopts',\n     'emaildomain',\n+    'scancommitforcc',\n     ]\n \n \n@@ -61,7 +63,7 @@ def _check_old_config_exists(old):\n     \"\"\"Check that at least one old configuration value is set.\"\"\"\n \n     for name in OLD_NAMES:\n-        if old.has_key(name):\n+        if name in old:\n             return True\n \n     return False\n@@ -72,7 +74,7 @@ def _check_new_config_clear(new):\n \n     retval = True\n     for name in NEW_NAMES:\n-        if new.has_key(name):\n+        if name in new:\n             if retval:\n                 sys.stderr.write('INFO: The following configuration values already exist:\\n\\n')\n             sys.stderr.write('    \"%s.%s\"\\n' % (new.section, name))\n@@ -83,7 +85,7 @@ def _check_new_config_clear(new):\n \n def erase_values(config, names):\n     for name in names:\n-        if config.has_key(name):\n+        if name in config:\n             try:\n                 sys.stderr.write('...unsetting \"%s.%s\"\\n' % (config.section, name))\n                 config.unset_all(name)\n@@ -170,7 +172,7 @@ def migrate_config(strict=False, retain=False, overwrite=False):\n                 )\n \n     name = 'showrev'\n-    if old.has_key(name):\n+    if name in old:\n         msg = 'git-multimail does not support \"%s.%s\"' % (old.section, name,)\n         if strict:\n             sys.exit(\n@@ -182,7 +184,7 @@ def migrate_config(strict=False, retain=False, overwrite=False):\n             sys.stderr.write('\\nWARNING: %s (ignoring).\\n\\n' % (msg,))\n \n     for name in ['mailinglist', 'announcelist']:\n-        if old.has_key(name):\n+        if name in old:\n             sys.stderr.write(\n                 '...copying \"%s.%s\" to \"%s.%s\"\\n' % (old.section, name, new.section, name)\n                 )\n@@ -198,15 +200,15 @@ def migrate_config(strict=False, retain=False, overwrite=False):\n             )\n         new.set('announceshortlog', 'true')\n \n-    for name in ['envelopesender', 'emailmaxlines', 'diffopts']:\n-        if old.has_key(name):\n+    for name in ['envelopesender', 'emailmaxlines', 'diffopts', 'scancommitforcc']:\n+        if name in old:\n             sys.stderr.write(\n                 '...copying \"%s.%s\" to \"%s.%s\"\\n' % (old.section, name, new.section, name)\n                 )\n             new.set(name, old.get(name))\n \n     name = 'emailprefix'\n-    if old.has_key(name):\n+    if name in old:\n         sys.stderr.write(\n             '...copying \"%s.%s\" to \"%s.%s\"\\n' % (old.section, name, new.section, name)\n             )\ndiff --git a/contrib/hooks/multimail/post-receive b/contrib/hooks/multimail/post-receive.example\nsimilarity index 74%\nrename from contrib/hooks/multimail/post-receive\nrename to contrib/hooks/multimail/post-receive.example\nindex 4d46828..43f7b6b 100755\n--- a/contrib/hooks/multimail/post-receive\n+++ b/contrib/hooks/multimail/post-receive.example\n@@ -2,16 +2,18 @@\n \n \"\"\"Example post-receive hook based on git-multimail.\n \n-This script is a simple example of a post-receive hook implemented\n-using git_multimail.py as a Python module.  It is intended to be\n-customized before use; see the comments in the script to help you get\n-started.\n+The simplest way to use git-multimail is to use the script\n+git_multimail.py directly as a post-receive hook, and to configure it\n+using Git's configuration files and command-line parameters.  You can\n+also write your own Python wrapper for more advanced configurability,\n+using git_multimail.py as a Python module.\n \n-It is possible to use git_multimail.py itself as a post-receive or\n-update hook, configured via git config settings and/or command-line\n-parameters.  But for more flexibility, it can also be imported as a\n-Python module by a custom post-receive script as done here.  The\n-latter has the following advantages:\n+This script is a simple example of such a post-receive hook.  It is\n+intended to be customized before use; see the comments in the script\n+to help you get started.\n+\n+Using git-multimail as a Python module as done here provides more\n+flexibility.  It has the following advantages:\n \n * The tool's behavior can be customized using arbitrary Python code,\n   without having to edit git_multimail.py.\n@@ -56,8 +58,11 @@ config = git_multimail.Config('multimailhook')\n \n \n # Select the type of environment:\n-environment = git_multimail.GenericEnvironment(config=config)\n-#environment = git_multimail.GitoliteEnvironment(config=config)\n+try:\n+    environment = git_multimail.GenericEnvironment(config=config)\n+    #environment = git_multimail.GitoliteEnvironment(config=config)\n+except git_multimail.ConfigurationException, e:\n+    sys.exit(str(e))\n \n \n # Choose the method of sending emails based on the git config:\n-- \n2.4.3.435.g7f6fd76\n"}]}