git-multimail: update to release 1.1.0
authorMatthieu Moy <Matthieu.Moy@imag.fr>
Thu, 18 Jun 2015 08:46:04 +0000 (10:46 +0200)
committerJunio C Hamano <gitster@pobox.com>
Thu, 18 Jun 2015 17:03:52 +0000 (10:03 -0700)
The changes are described in CHANGES.

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