git-multimail: update to release 1.2.0

The changes are described in CHANGES. Contributions-by: Matthieu Moy <Matthieu.Moy@imag.fr> Contributions-by: Elijah Newren <newren@palantir.com> Contributions-by: Edward d'Auvergne <edward@nmr-relax.com> Contributions-by: Vadim Zeitlin <vadim@zeitlins.org> Contributions-by: Paul Sokolovsky <paul.sokolovsky@linaro.org> Contributions-by: Michael Haggerty <mhagger@alum.mit.edu> Contributions-by: Elijah Newren <newren@gmail.com> Contributions-by: Richard Hansen <rhansen@rhansen.org> Contributions-by: Job Snijders <job@instituut.net> Signed-off-by: Matthieu Moy <Matthieu.Moy@imag.fr> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Matthieu Moy committed Oct 11, 2015 at 20:43 UTC 4b1fd356b8f8ca464203174a47ab5fa6a5cbc26b
9 files changed +1218 -226
contrib/hooks/multimail/CHANGES
+41
@@ -1,3 +1,44 @@
1 +Release 1.2.0
2 +=============
3 +
4 +* It is now possible to exclude some refs (e.g. exclude some branches
5 + or tags). See refFilterDoSendRegex, refFilterDontSendRegex,
6 + refFilterInclusionRegex and refFilterExclusionRegex.
7 +
8 +* New commitEmailFormat option which can be set to "html" to generate
9 + simple colorized diffs using HTML for the commit emails.
10 +
11 +* git-multimail can now be ran as a Gerrit ref-updated hook, or from
12 + Atlassian BitBucket Server (formerly known as Atlassian Stash).
13 +
14 +* The From: field is now more customizeable. It can be set
15 + independently for refchange emails and commit emails (see
16 + fromCommit, fromRefChange). The special values pusher and author can
17 + be used in these configuration variable.
18 +
19 +* A new command-line option, --version, was added. The version is also
20 + available in the X-Git-Multimail-Version header of sent emails.
21 +
22 +* Set X-Git-NotificationType header to differentiate the various types
23 + of notifications. Current values are: diff, ref_changed_plus_diff,
24 + ref_changed.
25 +
26 +* Preliminary support for Python 3. The testsuite passes with Python 3,
27 + but it has not received as much testing as the Python 2 version yet.
28 +
29 +* Several encoding-related fixes. UTF-8 characters work in more
30 + situations (but non-ascii characters in email address are still not
31 + supported).
32 +
33 +* The testsuite and its documentation has been greatly improved.
34 +
35 +Plus all the bugfixes from version 1.1.1.
36 +
37 +This version has been tested with Python 2.4 and 2.6 to 3.5, and Git
38 +v1.7.10-406-gdc801e7, git-1.8.2.3 and 2.6.0. Git versions prior to
39 +v1.7.10-406-gdc801e7 probably work, but cannot run the testsuite
40 +properly.
41 +
42 Release 1.1.1 (bugfix-only release)
43 ===================================
44
contrib/hooks/multimail/CONTRIBUTING.rst new
+30
@@ -0,0 +1,30 @@
1 +git-multimail is an open-source project, built by volunteers. We would
2 +welcome your help!
3 +
4 +The current maintainers are Michael Haggerty <mhagger@alum.mit.edu>
5 +and Matthieu Moy <matthieu.moy@grenoble-inp.fr>.
6 +
7 +Please note that although a copy of git-multimail is distributed in
8 +the "contrib" section of the main Git project, development takes place
9 +in a separate git-multimail repository on GitHub:
10 +
11 + https://github.com/git-multimail/git-multimail
12 +
13 +Whenever enough changes to git-multimail have accumulated, a new
14 +code-drop of git-multimail will be submitted for inclusion in the Git
15 +project.
16 +
17 +We use the GitHub issue tracker to keep track of bugs and feature
18 +requests, and we use GitHub pull requests to exchange patches (though,
19 +if you prefer, you can send patches via the Git mailing list with CC
20 +to the maintainers). Please sign off your patches as per the `Git
21 +project practice
22 +<https://github.com/git/git/blob/master/Documentation/SubmittingPatches#L234>`__.
23 +
24 +General discussion of git-multimail can take place on the main Git
25 +mailing list,
26 +
27 + git@vger.kernel.org
28 +
29 +Please CC emails regarding git-multimail to the maintainers so that we
30 +don't overlook them.
contrib/hooks/multimail/README
+175 -83
@@ -1,5 +1,5 @@
1 -git-multimail Version 1.1.1
2 -===========================
1 +git-multimail (version 1.2.0)
2 +=============================
3
4 .. image:: https://travis-ci.org/git-multimail/git-multimail.svg?branch=master
5 :target: https://travis-ci.org/git-multimail/git-multimail
@@ -53,11 +53,13 @@ By default, for each push received by the repository, git-multimail:
53 + [git] 07/08: Merge branch 'mm/api-credentials-doc'
54 + [git] 08/08: Git 1.7.11-rc2
55
56 - Each commit appears in exactly one commit email, the first time
57 - that it is pushed to the repository. If a commit is later merged
58 - into another branch, then a one-line summary of the commit is
59 - included in the reference change email (as usual), but no
60 - additional commit email is generated.
56 + By default, each commit appears in exactly one commit email, the
57 + first time that it is pushed to the repository. If a commit is later
58 + merged into another branch, then a one-line summary of the commit
59 + is included in the reference change email (as usual), but no
60 + additional commit email is generated. See
61 + `multimailhook.refFilter(Inclusion|Exclusion|DoSend|DontSend)Regex`
62 + below to configure which branches and tags are watched by the hook.
63
64 By default, reference change emails have their "Reply-To" field set
65 to the person who pushed the change, and commit emails have their
@@ -73,21 +75,8 @@ Requirements
75 ------------
76
77 * Python 2.x, version 2.4 or later. No non-standard Python modules
76 - are required. git-multimail does *not* currently work with Python
77 - 3.x.
78 -
79 - The example scripts invoke Python using the following shebang line
80 - (following PEP 394 [1]_)::
81 -
82 - #! /usr/bin/env python2
83 -
84 - If your system's Python2 interpreter is not in your PATH or is not
85 - called ``python2``, you can change the lines accordingly. Or you can
86 - invoke the Python interpreter explicitly, for example via a tiny
87 - shell script like::
88 -
89 - #! /bin/sh
90 - /usr/local/bin/python /path/to/git_multimail.py "$@"
78 + are required. git-multimail has preliminary support for Python 3
79 + (but it has been better tested with Python 2).
80
81 * The ``git`` command must be in your PATH. git-multimail is known to
82 work with Git versions back to 1.7.1. (Earlier versions have not
@@ -146,7 +135,9 @@ following ``git config`` settings:
135
136 multimailhook.environment
137
149 - This describes the general environment of the repository.
138 + This describes the general environment of the repository. In most
139 + cases, you do not need to specify a value for this variable:
140 + `git-multimail` will autodetect which environment to use.
141 Currently supported values:
142
143 * generic
@@ -161,18 +152,57 @@ multimailhook.environment
152 optionally read from gitolite.conf (see multimailhook.from).
153
154 For more information about gitolite and git-multimail, read
164 - doc/gitolite.rst
155 + `<doc/gitolite.rst>`__
156 +
157 + * stash
158 +
159 + Environment to use when ``git-multimail`` is ran as an Atlassian
160 + BitBucket Server (formerly known as Atlassian Stash) hook.
161 +
162 + **Warning:** this mode was provided by a third-party contributor
163 + and never tested by the git-multimail maintainers. It is
164 + provided as-is and may or may not work for you.
165 +
166 + This value is automatically assumed when the stash-specific
167 + flags (``--stash-user`` and ``--stash-repo``) are specified on
168 + the command line. When this environment is active, the username
169 + and repo come from these two command line flags, which must be
170 + specified.
171 +
172 + * gerrit
173 +
174 + Environment to use when ``git-multimail`` is ran as a
175 + ``ref-updated`` Gerrit hook.
176 +
177 + This value is used when the gerrit-specific command line flags
178 + (``--oldrev``, ``--newrev``, ``--refname``, ``--project``) for
179 + gerrit's ref-updated hook are present. When this environment is
180 + active, the username of the pusher is taken from the
181 + ``--submitter`` argument if that command line option is passed,
182 + otherwise 'Gerrit' is used. The repository name is taken from
183 + the ``--project`` option on the command line, which must be passed.
184 +
185 + For more information about gerrit and git-multimail, read
186 + `<doc/gerrit.rst>`__
187
166 - If neither of these environments is suitable for your setup, then
167 - you can implement a Python class that inherits from Environment
168 - and instantiate it via a script that looks like the example
188 + If none of these environments is suitable for your setup, then you
189 + can implement a Python class that inherits from Environment and
190 + instantiate it via a script that looks like the example
191 post-receive script.
192
193 The environment value can be specified on the command line using
172 - the --environment option. If it is not specified on the command
173 - line or by multimailhook.environment, then it defaults to
174 - ``gitolite`` if the environment contains variables $GL_USER and
175 - $GL_REPO; otherwise ``generic``.
194 + the ``--environment`` option. If it is not specified on the
195 + command line or by ``multimailhook.environment``, the value is
196 + guessed as follows:
197 +
198 + * If stash-specific (respectively gerrit-specific) command flags
199 + are present on the command-line, then ``stash`` (respectively
200 + ``gerrit``) is used.
201 +
202 + * If the environment variables $GL_USER and $GL_REPO are set, then
203 + ``gitolite`` is used.
204 +
205 + * If none of the above apply, then ``generic`` is used.
206
207 multimailhook.repoName
208
@@ -196,8 +226,8 @@ multimailhook.refchangeList
226 reference changes should be sent, as RFC 2822 email addresses
227 separated by commas. This configuration option can be
228 multivalued. The default is the value in
199 - multimailhook.mailingList. Set this value to the empty string to
200 - prevent reference change emails from being sent even if
229 + multimailhook.mailingList. Set this value to "none" (or the empty
230 + string) to prevent reference change emails from being sent even if
231 multimailhook.mailingList is set.
232
233 multimailhook.announceList
@@ -206,9 +236,9 @@ multimailhook.announceList
236 tags should be sent, as RFC 2822 email addresses separated by
237 commas. This configuration option can be multivalued. The
238 default is the value in multimailhook.refchangeList or
209 - multimailhook.mailingList. Set this value to the empty string to
210 - prevent annotated tag announcement emails from being sent even if
211 - one of the other values is set.
239 + multimailhook.mailingList. Set this value to "none" (or the empty
240 + string) to prevent annotated tag announcement emails from being sent
241 + even if one of the other values is set.
242
243 multimailhook.commitList
244
@@ -216,7 +246,7 @@ multimailhook.commitList
246 commits should be sent, as RFC 2822 email addresses separated by
247 commas. This configuration option can be multivalued. The
248 default is the value in multimailhook.mailingList. Set this value
219 - to the empty string to prevent notification emails about
249 + to "none" (or the empty string) to prevent notification emails about
250 individual commits from being sent even if
251 multimailhook.mailingList is set.
252
@@ -230,6 +260,20 @@ multimailhook.announceShortlog
260 not so straightforward, then the shortlog might be confusing
261 rather than useful. Default is false.
262
263 +multimailhook.commitEmailFormat
264 +
265 + The format of email messages for the individual commits, can be "text" or
266 + "html". In the latter case, the emails will include diffs using colorized
267 + HTML instead of plain text used by default. Note that this currently the
268 + ref change emails are always sent in plain text.
269 +
270 + Note that when using "html", the formatting is done by parsing the
271 + output of ``git log`` with ``-p``. When using
272 + ``multimailhook.commitLogOpts`` to specify a ``--format`` for
273 + ``git log``, one may get false positive (e.g. lines in the body of
274 + the message starting with ``+++`` or ``---`` colored in red or
275 + green).
276 +
277 multimailhook.refchangeShowGraph
278
279 If this option is set to true, then summary emails about reference
@@ -305,7 +349,7 @@ multimailhook.mailer
349
350 * multimailhook.smtpEncryption
351
308 - Set the security type. Allowed values: none, ssl.
352 + Set the security type. Allowed values: none, ssl, tls.
353 Default=none.
354
355 * multimailhook.smtpServerDebugLevel
@@ -313,9 +357,26 @@ multimailhook.mailer
357 Integer number. Set to greater than 0 to activate debugging.
358
359 multimailhook.from
360 +multimailhook.fromCommit
361 +multimailhook.fromRefchange
362 +
363 + If set, use this value in the From: field of generated emails.
364 + ``fromCommit`` is used for commit emails, ``fromRefchange`` is
365 + used for refchange emails, and ``from`` is used as fall-back in
366 + all cases.
367 +
368 + The value for these variables can be either:
369 +
370 + - An email address, which will be used directly.
371 +
372 + - The value ``pusher``, in which case the pusher's address (if
373 + available) will be used.
374
317 - If set, use this value in the From: field of generated emails. If
318 - unset, the value of the From: header is determined as follows:
375 + - The value ``author`` (meaningful only for replyToCommit), in which
376 + case the commit author's address will be used.
377 +
378 + If config values are unset, the value of the From: header is
379 + determined as follows:
380
381 1. (gitolite environment only) Parse gitolite.conf, looking for a
382 block of comments that looks like this::
@@ -425,6 +486,15 @@ multimailhook.commitLogOpts
486 --stat -p --cc``. Shell quoting is allowed; see
487 multimailhook.logOpts for details.
488
489 +multimailhook.dateSubstitute
490 +
491 + String to use as a substitute for ``Date:`` in the output of ``git
492 + log`` while formatting commit messages. This is usefull to avoid
493 + emitting a line that can be interpreted by mailers as the start of
494 + a cited message (Zimbra webmail in particular). Defaults to
495 + ``CommitDate: ``. Set to an empty string or ``none`` to deactivate
496 + the behavior.
497 +
498 multimailhook.emailDomain
499
500 Domain name appended to the username of the person doing the push
@@ -440,21 +510,13 @@ multimailhook.replyToRefchange
510 Addresses to use in the Reply-To: field for commit emails
511 (replyToCommit) and refchange emails (replyToRefchange).
512 multimailhook.replyTo is used as default when replyToCommit or
443 - replyToRefchange is not set. The value for these variables can be
444 - either:
445 -
446 - - An email address, which will be used directly.
447 -
448 - - The value `pusher`, in which case the pusher's address (if
449 - available) will be used. This is the default for refchange
450 - emails.
513 + replyToRefchange is not set. The shortcuts ``pusher`` and
514 + ``author`` are allowed with the same semantics as for
515 + ``multimailhook.from``. In addition, the value ``none`` can be
516 + used to omit the ``Reply-To:`` field.
517
452 - - The value `author` (meaningful only for replyToCommit), in which
453 - case the commit author's address will be used. This is the
454 - default for commit emails.
455 -
456 - - The value `none`, in which case the Reply-To: field will be
457 - omitted.
518 + The default is ``pusher`` for refchange emails, and ``author`` for
519 + commit emails.
520
521 multimailhook.quiet
522
@@ -478,6 +540,63 @@ multimailhook.combineWhenSingleCommit
540 single email.
541 Default: true
542
543 +multimailhook.refFilterInclusionRegex
544 +multimailhook.refFilterExclusionRegex
545 +multimailhook.refFilterDoSendRegex
546 +multimailhook.refFilterDontSendRegex
547 +
548 + **Warning:** these options are experimental. They should work, but
549 + the user-interface is not stable yet (in particular, the option
550 + names may change). If you want to participate in stabilizing the
551 + feature, please contact the maintainers and/or send pull-requests.
552 +
553 + Regular expressions that can be used to limit refs for which email
554 + updates will be sent. It is an error to specify both an inclusion
555 + and an exclusion regex. If a ``refFilterInclusionRegex`` is
556 + specified, emails will only be sent for refs which match this
557 + regex. If a ``refFilterExclusionRegex`` regex is specified,
558 + emails will be sent for all refs except those that match this
559 + regex (or that match a predefined regex specific to the
560 + environment, such as "^refs/notes" for most environments and
561 + "^refs/notes|^refs/changes" for the gerrit environment).
562 +
563 + The expressions are matched against the complete refname, and is
564 + considered to match if any substring matches. For example, to
565 + filter-out all tags, set ``refFilterExclusionRegex`` to
566 + ``^refs/tags/`` (note the leading ``^`` but no trailing ``$``). If
567 + you set ``refFilterExclusionRegex`` to ``master``, then any ref
568 + containing ``master`` will be excluded (the ``master`` branch, but
569 + also ``refs/tags/master`` or ``refs/heads/foo-master-bar``).
570 +
571 + ``refFilterDoSendRegex`` and ``refFilterDontSendRegex`` are
572 + analogous to ``refFilterInclusionRegex`` and
573 + ``refFilterExclusionRegex`` with one difference: with
574 + ``refFilterDoSendRegex`` and ``refFilterDontSendRegex``, commits
575 + introduced by one excluded ref will not be considered as new when
576 + they reach an included ref. Typically, if you add a branch ``foo``
577 + to ``refFilterDontSendRegex``, push commits to this branch, and
578 + later merge branch ``foo`` into ``master``, then the notification
579 + email for ``master`` will contain a commit email only for the
580 + merge commit. If you include ``foo`` in
581 + ``refFilterExclusionRegex``, then at the time of merge, you will
582 + receive one commit email per commit in the branch.
583 +
584 + These variables can be multi-valued, like::
585 +
586 + [multimailhook]
587 + refFilterExclusionRegex = ^refs/tags/
588 + refFilterExclusionRegex = ^refs/heads/master$
589 +
590 + You can also provide a whitespace-separated list like::
591 +
592 + [multimailhook]
593 + refFilterExclusionRegex = ^refs/tags/ ^refs/heads/master$
594 +
595 + Both examples exclude tags and the master branch, and are
596 + equivalent to::
597 +
598 + [multimailhook]
599 + refFilterExclusionRegex = ^refs/tags/|^refs/heads/master$
600
601 Email filtering aids
602 --------------------
@@ -547,35 +666,8 @@ consider sharing them with the community!
666 Getting involved
667 ----------------
668
550 -git-multimail is an open-source project, built by volunteers. We would
551 -welcome your help!
552 -
553 -The current maintainers are Michael Haggerty <mhagger@alum.mit.edu>
554 -and Matthieu Moy <matthieu.moy@grenoble-inp.fr>.
555 -
556 -Please note that although a copy of git-multimail is distributed in
557 -the "contrib" section of the main Git project, development takes place
558 -in a separate git-multimail repository on GitHub:
559 -
560 - https://github.com/git-multimail/git-multimail
561 -
562 -Whenever enough changes to git-multimail have accumulated, a new
563 -code-drop of git-multimail will be submitted for inclusion in the Git
564 -project.
565 -
566 -We use the GitHub issue tracker to keep track of bugs and feature
567 -requests, and we use GitHub pull requests to exchange patches (though,
568 -if you prefer, you can send patches via the Git mailing list with CC
569 -to the maintainers). Please sign off your patches as per the Git
570 -project practice.
571 -
572 -General discussion of git-multimail can take place on the main Git
573 -mailing list,
574 -
575 - git@vger.kernel.org
576 -
577 -Please CC emails regarding git-multimail to the maintainers so that we
578 -don't overlook them.
669 +Please, read `<CONTRIBUTING.rst>`__ for instructions on how to
670 +contribute to git-multimail.
671
672
673 Footnotes
contrib/hooks/multimail/README.Git
+2 -2
@@ -6,10 +6,10 @@ website:
6 https://github.com/git-multimail/git-multimail
7
8 The version in this directory was obtained from the upstream project
9 -on July 03 2015 and consists of the "git-multimail" subdirectory from
9 +on October 11 2015 and consists of the "git-multimail" subdirectory from
10 revision
11
12 - 6d6c9eb62a054143322cfaecde3949189c065b46 refs/tags/1.1.1
12 + c0791b9ef5821a746fc3475c25765e640452eaae refs/tags/1.2.0
13
14 Please see the README file in this directory for information about how
15 to report bugs or contribute to git-multimail.
contrib/hooks/multimail/doc/gerrit.rst new
+56
@@ -0,0 +1,56 @@
1 +Setting up git-multimail on Gerrit
2 +==================================
3 +
4 +Gerrit has its own email-sending system, but you may prefer using
5 +``git-multimail`` instead. It supports Gerrit natively as a Gerrit
6 +``ref-updated`` hook (Warning: `Gerrit hooks
7 +<https://gerrit-review.googlesource.com/Documentation/config-hooks.html>`__
8 +are distinct from Git hooks). Setting up ``git-multimail`` on a Gerrit
9 +installation can be done following the instructions below.
10 +
11 +The explanations show an easy way to set up ``git-multimail``,
12 +but leave ``git-multimail`` installed and unconfigured for a while. If
13 +you run Gerrit on a production server, it is advised that you
14 +execute the step "Set up the hook" last to avoid confusing your users
15 +in the meantime.
16 +
17 +Set up the hook
18 +---------------
19 +
20 +Create a directory ``$site_path/hooks/`` if it does not exist (if you
21 +don't know what ``$site_path`` is, run ``gerrit.sh status`` and look
22 +for a ``GERRIT_SITE`` line). Either copy ``git_multimail.py`` to
23 +``$site_path/hooks/ref-updated`` or create a wrapper script like
24 +this::
25 +
26 + #! /bin/sh
27 + exec /path/to/git_multimail.py "$@"
28 +
29 +In both cases, make sure the file is named exactly
30 +``$site_path/hooks/ref-updated`` and is executable.
31 +
32 +(Alternatively, you may configure the ``[hooks]`` section of
33 +gerrit.config)
34 +
35 +Configuration
36 +-------------
37 +
38 +Log on the gerrit server and edit ``$site_path/git/$project/config``
39 +to configure ``git-multimail``.
40 +
41 +Troubleshooting
42 +---------------
43 +
44 +Warning: this will disable ``git-multimail`` during the debug, and
45 +could confuse your users. Don't run on a production server.
46 +
47 +To debug configuration issues with ``git-multimail``, you can add the
48 +``--stdout`` option when calling ``git_multimail.py`` like this::
49 +
50 + #!/bin/sh
51 + exec /path/to/git-multimail/git-multimail/git_multimail.py \
52 + --stdout "$@" >> /tmp/log.txt
53 +
54 +and try pushing from a test repository. You should see the source of
55 +the email that would have been sent in the output of ``git push`` in
56 +the file ``/tmp/log.txt``.
contrib/hooks/multimail/doc/gitolite.rst new
+109
@@ -0,0 +1,109 @@
1 +Setting up git-multimail on gitolite
2 +====================================
3 +
4 +``git-multimail`` supports gitolite 3 natively.
5 +The explanations below show an easy way to set up ``git-multimail``,
6 +but leave ``git-multimail`` installed and unconfigured for a while. If
7 +you run gitolite on a production server, it is advised that you
8 +execute the step "Set up the hook" last to avoid confusing your users
9 +in the meantime.
10 +
11 +Set up the hook
12 +---------------
13 +
14 +Log in as your gitolite user.
15 +
16 +Create a file ``.gitolite/hooks/common/post-receive`` on your gitolite
17 +account containing (adapt the path, obviously)::
18 +
19 + #!/bin/sh
20 + exec /path/to/git-multimail/git-multimail/git_multimail.py "$@"
21 +
22 +Make sure it's executable (``chmod +x``). Record the hook in
23 +gitolite::
24 +
25 + gitolite setup
26 +
27 +Configuration
28 +-------------
29 +
30 +First, you have to allow the admin to set Git configuration variables.
31 +
32 +As gitolite user, edit the line containing ``GIT_CONFIG_KEYS`` in file
33 +``.gitolite.rc``, to make it look like::
34 +
35 + GIT_CONFIG_KEYS => 'multimailhook\..*',
36 +
37 +You can now log out and return to your normal user.
38 +
39 +In the ``gitolite-admin`` clone, edit the file ``conf/gitolite.conf``
40 +and add::
41 +
42 + repo @all
43 + # Not strictly needed as git_multimail.py will chose gitolite if
44 + # $GL_USER is set.
45 + config multimailhook.environment = gitolite
46 + config multimailhook.mailingList = # Where emails should be sent
47 + config multimailhook.from = # From address to use
48 +
49 +Obviously, you can customize all parameters on a per-repository basis by
50 +adding these ``config multimailhook.*`` lines in the section
51 +corresponding to a repository or set of repositories.
52 +
53 +To activate ``git-multimail`` on a per-repository basis, do not set
54 +``multimailhook.mailingList`` in the ``@all`` section and set it only
55 +for repositories for which you want ``git-multimail``.
56 +
57 +Alternatively, you can set up the ``From:`` field on a per-user basis
58 +by adding a ``BEGIN USER EMAILS``/``END USER EMAILS`` section (see
59 +``../README``).
60 +
61 +Specificities of Gitolite for Configuration
62 +-------------------------------------------
63 +
64 +Empty configuration variables
65 +.............................
66 +
67 +With gitolite, the syntax ``config multimailhook.commitList = ""``
68 +unsets the variable instead of setting it to an empty string (see
69 +`here
70 +<http://gitolite.com/gitolite/git-config.html#an-important-warning-about-deleting-a-config-line>`__).
71 +As a result, there is no way to set a variable to the empty string.
72 +In all most places where an empty value is required, git-multimail
73 +now allows to specify special ``"none"`` value (case-sensitive) to
74 +mean the same.
75 +
76 +Alternatively, one can use ``" "`` (a single space) instead of ``""``.
77 +In most cases (in particular ``multimailhook.*List`` variables), this
78 +will be equivalent to an empty string.
79 +
80 +If you have a use-case where ``"none"`` is not an acceptable value and
81 +you need ``" "`` or ``""`` instead, please report it as a bug to
82 +git-multimail.
83 +
84 +Allowing Regular Expressions in Configuration
85 +.............................................
86 +
87 +gitolite has a mechanism to prevent unsafe configuration variable
88 +values, which prevent characters like ``|`` commonly used in regular
89 +expressions. If you do not need the safety feature of gitolite and
90 +need to use regular expressions in your configuration (e.g. for
91 +``multimailhook.refFilter*`` variables), set
92 +`UNSAFE_PATT
93 +<http://gitolite.com/gitolite/git-config.html#unsafe-patt>`__ to a
94 +less restrictive value.
95 +
96 +Troubleshooting
97 +---------------
98 +
99 +Warning: this will disable ``git-multimail`` during the debug, and
100 +could confuse your users. Don't run on a production server.
101 +
102 +To debug configuration issues with ``git-multimail``, you can add the
103 +``--stdout`` option when calling ``git_multimail.py`` like this::
104 +
105 + #!/bin/sh
106 + exec /path/to/git-multimail/git-multimail/git_multimail.py --stdout "$@"
107 +
108 +and try pushing from a test repository. You should see the source of
109 +the email that would have been sent in the output of ``git push``.
contrib/hooks/multimail/git_multimail.py
+800 -136
@@ -1,4 +1,6 @@
1 -#! /usr/bin/env python2
1 +#! /usr/bin/env python
2 +
3 +__version__ = '1.2.0'
4
5 # Copyright (c) 2015 Matthieu Moy and others
6 # Copyright (c) 2012-2014 Michael Haggerty and others
@@ -56,8 +58,54 @@ import shlex
58 import optparse
59 import smtplib
60 import time
61 +import cgi
62 +
63 +PYTHON3 = sys.version_info >= (3, 0)
64 +
65 +if sys.version_info <= (2, 5):
66 + def all(iterable):
67 + for element in iterable:
68 + if not element:
69 + return False
70 + return True
71 +
72 +
73 +def is_ascii(s):
74 + return all(ord(c) < 128 and ord(c) > 0 for c in s)
75 +
76 +
77 +if PYTHON3:
78 + def str_to_bytes(s):
79 + return s.encode(ENCODING)
80 +
81 + def bytes_to_str(s):
82 + return s.decode(ENCODING)
83 +
84 + unicode = str
85 +
86 + def write_str(f, msg):
87 + # Try outputing with the default encoding. If it fails,
88 + # try UTF-8.
89 + try:
90 + f.buffer.write(msg.encode(sys.getdefaultencoding()))
91 + except UnicodeEncodeError:
92 + f.buffer.write(msg.encode(ENCODING))
93 +else:
94 + def str_to_bytes(s):
95 + return s
96 +
97 + def bytes_to_str(s):
98 + return s
99 +
100 + def write_str(f, msg):
101 + f.write(msg)
102 +
103 + def next(it):
104 + return it.next()
105 +
106
107 try:
108 + from email.charset import Charset
109 from email.utils import make_msgid
110 from email.utils import getaddresses
111 from email.utils import formataddr
@@ -65,6 +113,7 @@ try:
113 from email.header import Header
114 except ImportError:
115 # Prior to Python 2.5, the email module used different names:
116 + from email.Charset import Charset
117 from email.Utils import make_msgid
118 from email.Utils import getaddresses
119 from email.Utils import formataddr
@@ -109,7 +158,7 @@ Date: %(send_date)s
158 To: %(recipients)s
159 Subject: %(subject)s
160 MIME-Version: 1.0
112 -Content-Type: text/plain; charset=%(charset)s
161 +Content-Type: text/%(contenttype)s; charset=%(charset)s
162 Content-Transfer-Encoding: 8bit
163 Message-ID: %(msgid)s
164 From: %(fromaddr)s
@@ -120,6 +169,8 @@ X-Git-Refname: %(refname)s
169 X-Git-Reftype: %(refname_type)s
170 X-Git-Oldrev: %(oldrev)s
171 X-Git-Newrev: %(newrev)s
172 +X-Git-NotificationType: ref_changed
173 +X-Git-Multimail-Version: %(multimail_version)s
174 Auto-Submitted: auto-generated
175 """
176
@@ -238,7 +289,7 @@ To: %(recipients)s
289 Cc: %(cc_recipients)s
290 Subject: %(emailprefix)s%(num)02d/%(tot)02d: %(oneline)s
291 MIME-Version: 1.0
241 -Content-Type: text/plain; charset=%(charset)s
292 +Content-Type: text/%(contenttype)s; charset=%(charset)s
293 Content-Transfer-Encoding: 8bit
294 From: %(fromaddr)s
295 Reply-To: %(reply_to)s
@@ -249,6 +300,8 @@ X-Git-Repo: %(repo_shortname)s
300 X-Git-Refname: %(refname)s
301 X-Git-Reftype: %(refname_type)s
302 X-Git-Rev: %(rev)s
303 +X-Git-NotificationType: diff
304 +X-Git-Multimail-Version: %(multimail_version)s
305 Auto-Submitted: auto-generated
306 """
307
@@ -270,7 +323,7 @@ Date: %(send_date)s
323 To: %(recipients)s
324 Subject: %(subject)s
325 MIME-Version: 1.0
273 -Content-Type: text/plain; charset=%(charset)s
326 +Content-Type: text/%(contenttype)s; charset=%(charset)s
327 Content-Transfer-Encoding: 8bit
328 Message-ID: %(msgid)s
329 From: %(fromaddr)s
@@ -282,6 +335,8 @@ X-Git-Reftype: %(refname_type)s
335 X-Git-Oldrev: %(oldrev)s
336 X-Git-Newrev: %(newrev)s
337 X-Git-Rev: %(rev)s
338 +X-Git-NotificationType: ref_changed_plus_diff
339 +X-Git-Multimail-Version: %(multimail_version)s
340 Auto-Submitted: auto-generated
341 """
342
@@ -352,12 +407,14 @@ def read_git_output(args, input=None, keepends=False, **kw):
407 def read_output(cmd, input=None, keepends=False, **kw):
408 if input:
409 stdin = subprocess.PIPE
410 + input = str_to_bytes(input)
411 else:
412 stdin = None
413 p = subprocess.Popen(
414 cmd, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw
415 )
416 (out, err) = p.communicate(input)
417 + out = bytes_to_str(out)
418 retcode = p.wait()
419 if retcode:
420 raise CommandError(cmd, retcode)
@@ -418,26 +475,37 @@ def git_log(spec, **kw):
475 def header_encode(text, header_name=None):
476 """Encode and line-wrap the value of an email header field."""
477
421 - try:
422 - if isinstance(text, str):
423 - text = text.decode(ENCODING, 'replace')
424 - return Header(text, header_name=header_name).encode()
425 - except UnicodeEncodeError:
426 - return Header(text, header_name=header_name, charset=CHARSET,
427 - errors='replace').encode()
478 + # Convert to unicode, if required.
479 + if not isinstance(text, unicode):
480 + text = unicode(text, 'utf-8')
481 +
482 + if is_ascii(text):
483 + charset = 'ascii'
484 + else:
485 + charset = 'utf-8'
486 +
487 + return Header(text, header_name=header_name, charset=Charset(charset)).encode()
488
489
490 def addr_header_encode(text, header_name=None):
491 """Encode and line-wrap the value of an email header field containing
492 email addresses."""
493
434 - return Header(
435 - ', '.join(
436 - formataddr((header_encode(name), emailaddr))
437 - for name, emailaddr in getaddresses([text])
438 - ),
439 - header_name=header_name
440 - ).encode()
494 + # Convert to unicode, if required.
495 + if not isinstance(text, unicode):
496 + text = unicode(text, 'utf-8')
497 +
498 + text = ', '.join(
499 + formataddr((header_encode(name), emailaddr))
500 + for name, emailaddr in getaddresses([text])
501 + )
502 +
503 + if is_ascii(text):
504 + charset = 'ascii'
505 + else:
506 + charset = 'utf-8'
507 +
508 + return Header(text, header_name=header_name, charset=Charset(charset)).encode()
509
510
511 class Config(object):
@@ -496,7 +564,8 @@ class Config(object):
564 ['config', '--get-all', '--null', '%s.%s' % (self.section, name)],
565 env=self.env, keepends=True,
566 ))
499 - except CommandError, e:
567 + except CommandError:
568 + t, e, traceback = sys.exc_info()
569 if e.retcode == 1:
570 # "the section or key is invalid"; i.e., there is no
571 # value for the specified key.
@@ -504,18 +573,6 @@ class Config(object):
573 else:
574 raise
575
507 - def get_recipients(self, name, default=None):
508 - """Read a recipients list from the configuration.
509 -
510 - Return the result as a comma-separated list of email
511 - addresses, or default if the option is unset. If the setting
512 - has multiple values, concatenate them with comma separators."""
513 -
514 - lines = self.get_all(name, default=None)
515 - if lines is None:
516 - return default
517 - return ', '.join(line.strip() for line in lines)
518 -
576 def set(self, name, value):
577 read_git_output(
578 ['config', '%s.%s' % (self.section, name), value],
@@ -542,7 +599,8 @@ class Config(object):
599 ['config', '--unset-all', '%s.%s' % (self.section, name)],
600 env=self.env,
601 )
545 - except CommandError, e:
602 + except CommandError:
603 + t, e, traceback = sys.exc_info()
604 if e.retcode == 5:
605 # The name doesn't exist, which is what we wanted anyway...
606 pass
@@ -636,7 +694,7 @@ class GitObject(object):
694 if not self.sha1:
695 raise ValueError('Empty commit has no summary')
696
639 - return iter(generate_summaries('--no-walk', self.sha1)).next()
697 + return next(iter(generate_summaries('--no-walk', self.sha1)))
698
699 def __eq__(self, other):
700 return isinstance(other, GitObject) and self.sha1 == other.sha1
@@ -647,6 +705,10 @@ class GitObject(object):
705 def __nonzero__(self):
706 return bool(self.sha1)
707
708 + def __bool__(self):
709 + """Python 2 backward compatibility"""
710 + return self.__nonzero__()
711 +
712 def __str__(self):
713 return self.sha1 or ZEROS
714
@@ -661,6 +723,12 @@ class Change(object):
723 def __init__(self, environment):
724 self.environment = environment
725 self._values = None
726 + self._contains_html_diff = False
727 +
728 + def _contains_diff(self):
729 + # We do contain a diff, should it be rendered in HTML?
730 + if self.environment.commit_email_format == "html":
731 + self._contains_html_diff = True
732
733 def _compute_values(self):
734 """Return a dictionary {keyword: expansion} for this Change.
@@ -670,7 +738,12 @@ class Change(object):
738 get_values(). The return value should always be a new
739 dictionary."""
740
673 - return self.environment.get_values()
741 + values = self.environment.get_values()
742 + fromaddr = self.environment.get_fromaddr(change=self)
743 + if fromaddr is not None:
744 + values['fromaddr'] = fromaddr
745 + values['multimail_version'] = get_version()
746 + return values
747
748 def get_values(self, **extra_values):
749 """Return a dictionary {keyword: expansion} for this Change.
@@ -713,12 +786,18 @@ class Change(object):
786 skip lines that contain references to unknown variables."""
787
788 values = self.get_values(**extra_values)
789 + if self._contains_html_diff:
790 + values['contenttype'] = 'html'
791 + else:
792 + values['contenttype'] = 'plain'
793 +
794 for line in template.splitlines():
717 - (name, value) = line.split(':', 1)
795 + (name, value) = line.split(': ', 1)
796
797 try:
798 value = value % values
721 - except KeyError, e:
799 + except KeyError:
800 + t, e, traceback = sys.exc_info()
801 if DEBUG:
802 self.environment.log_warning(
803 'Warning: unknown variable %r in the following line; line skipped:\n'
@@ -764,6 +843,24 @@ class Change(object):
843
844 raise NotImplementedError()
845
846 + def _wrap_for_html(self, lines):
847 + """Wrap the lines in HTML <pre> tag when using HTML format.
848 +
849 + Escape special HTML characters and add <pre> and </pre> tags around
850 + the given lines if we should be generating HTML as indicated by
851 + self._contains_html_diff being set to true.
852 + """
853 + if self._contains_html_diff:
854 + yield "<pre style='margin:0'>\n"
855 +
856 + for line in lines:
857 + yield cgi.escape(line)
858 +
859 + yield '</pre>\n'
860 + else:
861 + for line in lines:
862 + yield line
863 +
864 def generate_email(self, push, body_filter=None, extra_header_values={}):
865 """Generate an email describing this change.
866
@@ -779,18 +876,76 @@ class Change(object):
876 for line in self.generate_email_header(**extra_header_values):
877 yield line
878 yield '\n'
782 - for line in self.generate_email_intro():
879 + for line in self._wrap_for_html(self.generate_email_intro()):
880 yield line
881
882 body = self.generate_email_body(push)
883 if body_filter is not None:
884 body = body_filter(body)
885 +
886 + diff_started = False
887 + if self._contains_html_diff:
888 + # "white-space: pre" is the default, but we need to
889 + # specify it again in case the message is viewed in a
890 + # webmail which wraps it in an element setting white-space
891 + # to something else (Zimbra does this and sets
892 + # white-space: pre-line).
893 + yield '<pre style="white-space: pre; background: #F8F8F8">'
894 for line in body:
895 + if self._contains_html_diff:
896 + # This is very, very naive. It would be much better to really
897 + # parse the diff, i.e. look at how many lines do we have in
898 + # the hunk headers instead of blindly highlighting everything
899 + # that looks like it might be part of a diff.
900 + bgcolor = ''
901 + fgcolor = ''
902 + if line.startswith('--- a/'):
903 + diff_started = True
904 + bgcolor = 'e0e0ff'
905 + elif line.startswith('diff ') or line.startswith('index '):
906 + diff_started = True
907 + fgcolor = '808080'
908 + elif diff_started:
909 + if line.startswith('+++ '):
910 + bgcolor = 'e0e0ff'
911 + elif line.startswith('@@'):
912 + bgcolor = 'e0e0e0'
913 + elif line.startswith('+'):
914 + bgcolor = 'e0ffe0'
915 + elif line.startswith('-'):
916 + bgcolor = 'ffe0e0'
917 + elif line.startswith('commit '):
918 + fgcolor = '808000'
919 + elif line.startswith(' '):
920 + fgcolor = '404040'
921 +
922 + # Chop the trailing LF, we don't want it inside <pre>.
923 + line = cgi.escape(line[:-1])
924 +
925 + if bgcolor or fgcolor:
926 + style = 'display:block; white-space:pre;'
927 + if bgcolor:
928 + style += 'background:#' + bgcolor + ';'
929 + if fgcolor:
930 + style += 'color:#' + fgcolor + ';'
931 + # Use a <span style='display:block> to color the
932 + # whole line. The newline must be inside the span
933 + # to display properly both in Firefox and in
934 + # text-based browser.
935 + line = "<span style='%s'>%s\n</span>" % (style, line)
936 + else:
937 + line = line + '\n'
938 +
939 yield line
940 + if self._contains_html_diff:
941 + yield '</pre>'
942
791 - for line in self.generate_email_footer():
943 + for line in self._wrap_for_html(self.generate_email_footer()):
944 yield line
945
946 + def get_alt_fromaddr(self):
947 + return None
948 +
949
950 class Revision(Change):
951 """A Change consisting of a single git commit."""
@@ -867,14 +1022,25 @@ class Revision(Change):
1022 def generate_email_body(self, push):
1023 """Show this revision."""
1024
870 - return read_git_lines(
871 - ['log'] + self.environment.commitlogopts + ['-1', self.rev.sha1],
872 - keepends=True,
873 - )
1025 + for line in read_git_lines(
1026 + ['log'] + self.environment.commitlogopts + ['-1', self.rev.sha1],
1027 + keepends=True,
1028 + ):
1029 + if line.startswith('Date: ') and self.environment.date_substitute:
1030 + yield self.environment.date_substitute + line[len('Date: '):]
1031 + else:
1032 + yield line
1033
1034 def generate_email_footer(self):
1035 return self.expand_lines(REVISION_FOOTER_TEMPLATE)
1036
1037 + def generate_email(self, push, body_filter=None, extra_header_values={}):
1038 + self._contains_diff()
1039 + return Change.generate_email(self, push, body_filter, extra_header_values)
1040 +
1041 + def get_alt_fromaddr(self):
1042 + return self.environment.from_commit
1043 +
1044
1045 class ReferenceChange(Change):
1046 """A Change to a Git reference.
@@ -1096,10 +1262,10 @@ class ReferenceChange(Change):
1262 yield '\n'
1263 yield 'Detailed log of new commits:\n\n'
1264 for line in read_git_lines(
1099 - ['log', '--no-walk']
1100 - + self.logopts
1101 - + new_commits_list
1102 - + ['--'],
1265 + ['log', '--no-walk'] +
1266 + self.logopts +
1267 + new_commits_list +
1268 + ['--'],
1269 keepends=True,
1270 ):
1271 yield line
@@ -1253,9 +1419,9 @@ class ReferenceChange(Change):
1419 yield '\n'
1420 yield 'Summary of changes:\n'
1421 for line in read_git_lines(
1256 - ['diff-tree']
1257 - + self.diffopts
1258 - + ['%s..%s' % (self.old.commit_sha1, self.new.commit_sha1,)],
1422 + ['diff-tree'] +
1423 + self.diffopts +
1424 + ['%s..%s' % (self.old.commit_sha1, self.new.commit_sha1,)],
1425 keepends=True,
1426 ):
1427 yield line
@@ -1316,6 +1482,9 @@ class ReferenceChange(Change):
1482 )
1483 yield '\n'
1484
1485 + def get_alt_fromaddr(self):
1486 + return self.environment.from_refchange
1487 +
1488
1489 class BranchChange(ReferenceChange):
1490 refname_type = 'branch'
@@ -1397,9 +1566,9 @@ class BranchChange(ReferenceChange):
1566 # commit is a non-merge commit, though it may make sense to
1567 # combine if it is a merge as well.
1568 if not (
1400 - len(new_commits) == 1
1401 - and len(new_commits[0][1]) == 1
1402 - and new_commits[0][0] in known_added_sha1s
1569 + len(new_commits) == 1 and
1570 + len(new_commits[0][1]) == 1 and
1571 + new_commits[0][0] in known_added_sha1s
1572 ):
1573 return None
1574
@@ -1432,6 +1601,7 @@ class BranchChange(ReferenceChange):
1601 values['subject'] = self.expand(COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE, **values)
1602
1603 self._single_revision = revision
1604 + self._contains_diff()
1605 self.header_template = COMBINED_HEADER_TEMPLATE
1606 self.intro_template = COMBINED_INTRO_TEMPLATE
1607 self.footer_template = COMBINED_FOOTER_TEMPLATE
@@ -1690,17 +1860,18 @@ class SendMailer(Mailer):
1860 def send(self, lines, to_addrs):
1861 try:
1862 p = subprocess.Popen(self.command, stdin=subprocess.PIPE)
1693 - except OSError, e:
1863 + except OSError:
1864 sys.stderr.write(
1695 - '*** Cannot execute command: %s\n' % ' '.join(self.command)
1696 - + '*** %s\n' % str(e)
1697 - + '*** Try setting multimailhook.mailer to "smtp"\n'
1865 + '*** Cannot execute command: %s\n' % ' '.join(self.command) +
1866 + '*** %s\n' % sys.exc_info()[1] +
1867 + '*** Try setting multimailhook.mailer to "smtp"\n' +
1868 '*** to send emails without using the sendmail command.\n'
1869 )
1870 sys.exit(1)
1871 try:
1872 + lines = (str_to_bytes(line) for line in lines)
1873 p.stdin.writelines(lines)
1703 - except Exception, e:
1874 + except Exception:
1875 sys.stderr.write(
1876 '*** Error while generating commit email\n'
1877 '*** - mail sending aborted.\n'
@@ -1710,7 +1881,7 @@ class SendMailer(Mailer):
1881 p.terminate()
1882 except AttributeError:
1883 pass
1713 - raise e
1884 + raise
1885 else:
1886 p.stdin.close()
1887 retcode = p.wait()
@@ -1770,11 +1941,11 @@ class SMTPMailer(Mailer):
1941 "*** Setting debug on for SMTP server connection (%s) ***\n"
1942 % self.smtpserverdebuglevel)
1943 self.smtp.set_debuglevel(self.smtpserverdebuglevel)
1773 - except Exception, e:
1944 + except Exception:
1945 sys.stderr.write(
1946 '*** Error establishing SMTP connection to %s ***\n'
1947 % self.smtpserver)
1777 - sys.stderr.write('*** %s\n' % str(e))
1948 + sys.stderr.write('*** %s\n' % sys.exc_info()[1])
1949 sys.exit(1)
1950
1951 def __del__(self):
@@ -1784,16 +1955,15 @@ class SMTPMailer(Mailer):
1955 def send(self, lines, to_addrs):
1956 try:
1957 if self.username or self.password:
1787 - sys.stderr.write("*** Authenticating as %s ***\n" % self.username)
1958 self.smtp.login(self.username, self.password)
1959 msg = ''.join(lines)
1960 # turn comma-separated list into Python list if needed.
1961 if isinstance(to_addrs, basestring):
1962 to_addrs = [email for (name, email) in getaddresses([to_addrs])]
1963 self.smtp.sendmail(self.envelopesender, to_addrs, msg)
1794 - except Exception, e:
1964 + except Exception:
1965 sys.stderr.write('*** Error sending email ***\n')
1796 - sys.stderr.write('*** %s\n' % str(e))
1966 + sys.stderr.write('*** %s\n' % sys.exc_info()[1])
1967 self.smtp.quit()
1968 sys.exit(1)
1969
@@ -1809,9 +1979,10 @@ class OutputMailer(Mailer):
1979 self.f = f
1980
1981 def send(self, lines, to_addrs):
1812 - self.f.write(self.SEPARATOR)
1813 - self.f.writelines(lines)
1814 - self.f.write(self.SEPARATOR)
1982 + write_str(self.f, self.SEPARATOR)
1983 + for line in lines:
1984 + write_str(self.f, line)
1985 + write_str(self.f, self.SEPARATOR)
1986
1987
1988 def get_git_dir():
@@ -1877,11 +2048,13 @@ class Environment(object):
2048 Return the address to be used as the 'From' email address
2049 in the email envelope.
2050
1880 - get_fromaddr()
2051 + get_fromaddr(change=None)
2052
2053 Return the 'From' email address used in the email 'From:'
1883 - headers. (May be a full RFC 2822 email address like 'Joe
1884 - User <user@example.com>'.)
2054 + headers. If the change is known when this function is
2055 + called, it is passed in as the 'change' parameter. (May
2056 + be a full RFC 2822 email address like 'Joe User
2057 + <user@example.com>'.)
2058
2059 get_administrator()
2060
@@ -1901,12 +2074,29 @@ class Environment(object):
2074 get_reply_to_commit() is used for individual commit
2075 emails.
2076
2077 + get_ref_filter_regex()
2078 +
2079 + Return a tuple -- a compiled regex, and a boolean indicating
2080 + whether the regex picks refs to include (if False, the regex
2081 + matches on refs to exclude).
2082 +
2083 + get_default_ref_ignore_regex()
2084 +
2085 + Return a regex that should be ignored for both what emails
2086 + to send and when computing what commits are considered new
2087 + to the repository. Default is "^refs/notes/".
2088 +
2089 They should also define the following attributes:
2090
2091 announce_show_shortlog (bool)
2092
2093 True iff announce emails should include a shortlog.
2094
2095 + commit_email_format (string)
2096 +
2097 + If "html", generate commit emails in HTML instead of plain text
2098 + used by default.
2099 +
2100 refchange_showgraph (bool)
2101
2102 True iff refchanges emails should include a detailed graph.
@@ -1939,6 +2129,11 @@ class Environment(object):
2129 commit mail. The value should be a list of strings
2130 representing words to be passed to the command.
2131
2132 + date_substitute (string)
2133 +
2134 + String to be used in substitution for 'Date:' at start of
2135 + line in the output of 'git log'.
2136 +
2137 quiet (bool)
2138 On success do not write to stderr
2139
@@ -1950,6 +2145,13 @@ class Environment(object):
2145 True if a combined email should be produced when a single
2146 new commit is pushed to a branch, False otherwise.
2147
2148 + from_refchange, from_commit (strings)
2149 +
2150 + Addresses to use for the From: field for refchange emails
2151 + and commit emails respectively. Set from
2152 + multimailhook.fromRefchange and multimailhook.fromCommit
2153 + by ConfigEnvironmentMixin.
2154 +
2155 """
2156
2157 REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$')
@@ -1957,6 +2159,7 @@ class Environment(object):
2159 def __init__(self, osenv=None):
2160 self.osenv = osenv or os.environ
2161 self.announce_show_shortlog = False
2162 + self.commit_email_format = "text"
2163 self.maxcommitemails = 500
2164 self.diffopts = ['--stat', '--summary', '--find-copies-harder']
2165 self.graphopts = ['--oneline', '--decorate']
@@ -1964,6 +2167,7 @@ class Environment(object):
2167 self.refchange_showgraph = False
2168 self.refchange_showlog = False
2169 self.commitlogopts = ['-C', '--stat', '-p', '--cc']
2170 + self.date_substitute = 'AuthorDate: '
2171 self.quiet = False
2172 self.stdout = False
2173 self.combine_when_single_commit = True
@@ -1972,7 +2176,6 @@ class Environment(object):
2176 'administrator',
2177 'charset',
2178 'emailprefix',
1975 - 'fromaddr',
2179 'pusher',
2180 'pusher_email',
2181 'repo_path',
@@ -1998,7 +2201,7 @@ class Environment(object):
2201 def get_pusher_email(self):
2202 return None
2203
2001 - def get_fromaddr(self):
2204 + def get_fromaddr(self, change=None):
2205 config = Config('user')
2206 fromname = config.get('name', default='')
2207 fromemail = config.get('email', default='')
@@ -2080,6 +2283,15 @@ class Environment(object):
2283 def get_reply_to_commit(self, revision):
2284 return revision.author
2285
2286 + def get_default_ref_ignore_regex(self):
2287 + # The commit messages of git notes are essentially meaningless
2288 + # and "filenames" in git notes commits are an implementational
2289 + # detail that might surprise users at first. As such, we
2290 + # would need a completely different method for handling emails
2291 + # of git notes in order for them to be of benefit for users,
2292 + # which we simply do not have right now.
2293 + return "^refs/notes/"
2294 +
2295 def filter_body(self, lines):
2296 """Filter the lines intended for an email body.
2297
@@ -2095,19 +2307,19 @@ class Environment(object):
2307 """Write the string msg on a log file or on stderr.
2308
2309 Sends the text to stderr by default, override to change the behavior."""
2098 - sys.stderr.write(msg)
2310 + write_str(sys.stderr, msg)
2311
2312 def log_warning(self, msg):
2313 """Write the string msg on a log file or on stderr.
2314
2315 Sends the text to stderr by default, override to change the behavior."""
2104 - sys.stderr.write(msg)
2316 + write_str(sys.stderr, msg)
2317
2318 def log_error(self, msg):
2319 """Write the string msg on a log file or on stderr.
2320
2321 Sends the text to stderr by default, override to change the behavior."""
2110 - sys.stderr.write(msg)
2322 + write_str(sys.stderr, msg)
2323
2324
2325 class ConfigEnvironmentMixin(Environment):
@@ -2128,6 +2340,14 @@ class ConfigEnvironmentMixin(Environment):
2340 class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):
2341 """An Environment that reads most of its information from "git config"."""
2342
2343 + @staticmethod
2344 + def forbid_field_values(name, value, forbidden):
2345 + for forbidden_val in forbidden:
2346 + if value is not None and value.lower() == forbidden:
2347 + raise ConfigurationException(
2348 + '"%s" is not an allowed setting for %s' % (value, name)
2349 + )
2350 +
2351 def __init__(self, config, **kw):
2352 super(ConfigOptionsEnvironmentMixin, self).__init__(
2353 config=config, **kw
@@ -2144,14 +2364,26 @@ class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):
2364 if val is not None:
2365 setattr(self, var, val)
2366
2367 + commit_email_format = config.get('commitEmailFormat')
2368 + if commit_email_format is not None:
2369 + if commit_email_format != "html" and commit_email_format != "text":
2370 + self.log_warning(
2371 + '*** Unknown value for multimailhook.commitEmailFormat: %s\n' %
2372 + commit_email_format +
2373 + '*** Expected either "text" or "html". Ignoring.\n'
2374 + )
2375 + else:
2376 + self.commit_email_format = commit_email_format
2377 +
2378 maxcommitemails = config.get('maxcommitemails')
2379 if maxcommitemails is not None:
2380 try:
2381 self.maxcommitemails = int(maxcommitemails)
2382 except ValueError:
2383 self.log_warning(
2153 - '*** Malformed value for multimailhook.maxCommitEmails: %s\n' % maxcommitemails
2154 - + '*** Expected a number. Ignoring.\n'
2384 + '*** Malformed value for multimailhook.maxCommitEmails: %s\n'
2385 + % maxcommitemails +
2386 + '*** Expected a number. Ignoring.\n'
2387 )
2388
2389 diffopts = config.get('diffopts')
@@ -2170,32 +2402,44 @@ class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):
2402 if commitlogopts is not None:
2403 self.commitlogopts = shlex.split(commitlogopts)
2404
2405 + date_substitute = config.get('dateSubstitute')
2406 + if date_substitute == 'none':
2407 + self.date_substitute = None
2408 + elif date_substitute is not None:
2409 + self.date_substitute = date_substitute
2410 +
2411 reply_to = config.get('replyTo')
2412 self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to)
2175 - if (
2176 - self.__reply_to_refchange is not None
2177 - and self.__reply_to_refchange.lower() == 'author'
2178 - ):
2179 - raise ConfigurationException(
2180 - '"author" is not an allowed setting for replyToRefchange'
2181 - )
2413 + self.forbid_field_values('replyToRefchange',
2414 + self.__reply_to_refchange,
2415 + ['author'])
2416 self.__reply_to_commit = config.get('replyToCommit', default=reply_to)
2417
2418 + from_addr = self.config.get('from')
2419 + self.from_refchange = config.get('fromRefchange')
2420 + self.forbid_field_values('fromRefchange',
2421 + self.from_refchange,
2422 + ['author', 'none'])
2423 + self.from_commit = config.get('fromCommit')
2424 + self.forbid_field_values('fromCommit',
2425 + self.from_commit,
2426 + ['none'])
2427 +
2428 combine = config.get_bool('combineWhenSingleCommit')
2429 if combine is not None:
2430 self.combine_when_single_commit = combine
2431
2432 def get_administrator(self):
2433 return (
2190 - self.config.get('administrator')
2191 - or self.get_sender()
2192 - or super(ConfigOptionsEnvironmentMixin, self).get_administrator()
2434 + self.config.get('administrator') or
2435 + self.get_sender() or
2436 + super(ConfigOptionsEnvironmentMixin, self).get_administrator()
2437 )
2438
2439 def get_repo_shortname(self):
2440 return (
2197 - self.config.get('reponame')
2198 - or super(ConfigOptionsEnvironmentMixin, self).get_repo_shortname()
2441 + self.config.get('reponame') or
2442 + super(ConfigOptionsEnvironmentMixin, self).get_repo_shortname()
2443 )
2444
2445 def get_emailprefix(self):
@@ -2212,33 +2456,42 @@ class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):
2456 def get_sender(self):
2457 return self.config.get('envelopesender')
2458
2215 - def get_fromaddr(self):
2459 + def process_addr(self, addr, change):
2460 + if addr.lower() == 'author':
2461 + if hasattr(change, 'author'):
2462 + return change.author
2463 + else:
2464 + return None
2465 + elif addr.lower() == 'pusher':
2466 + return self.get_pusher_email()
2467 + elif addr.lower() == 'none':
2468 + return None
2469 + else:
2470 + return addr
2471 +
2472 + def get_fromaddr(self, change=None):
2473 fromaddr = self.config.get('from')
2474 + if change:
2475 + alt_fromaddr = change.get_alt_fromaddr()
2476 + if alt_fromaddr:
2477 + fromaddr = alt_fromaddr
2478 + if fromaddr:
2479 + fromaddr = self.process_addr(fromaddr, change)
2480 if fromaddr:
2481 return fromaddr
2219 - return super(ConfigOptionsEnvironmentMixin, self).get_fromaddr()
2482 + return super(ConfigOptionsEnvironmentMixin, self).get_fromaddr(change)
2483
2484 def get_reply_to_refchange(self, refchange):
2485 if self.__reply_to_refchange is None:
2486 return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_refchange(refchange)
2224 - elif self.__reply_to_refchange.lower() == 'pusher':
2225 - return self.get_pusher_email()
2226 - elif self.__reply_to_refchange.lower() == 'none':
2227 - return None
2487 else:
2229 - return self.__reply_to_refchange
2488 + return self.process_addr(self.__reply_to_refchange, refchange)
2489
2490 def get_reply_to_commit(self, revision):
2491 if self.__reply_to_commit is None:
2492 return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_commit(revision)
2234 - elif self.__reply_to_commit.lower() == 'author':
2235 - return revision.author
2236 - elif self.__reply_to_commit.lower() == 'pusher':
2237 - return self.get_pusher_email()
2238 - elif self.__reply_to_commit.lower() == 'none':
2239 - return None
2493 else:
2241 - return self.__reply_to_commit
2494 + return self.process_addr(self.__reply_to_commit, revision)
2495
2496 def get_scancommitforcc(self):
2497 return self.config.get('scancommitforcc')
@@ -2270,12 +2523,14 @@ class FilterLinesEnvironmentMixin(Environment):
2523 def filter_body(self, lines):
2524 lines = super(FilterLinesEnvironmentMixin, self).filter_body(lines)
2525 if self.__strict_utf8:
2273 - lines = (line.decode(ENCODING, 'replace') for line in lines)
2526 + if not PYTHON3:
2527 + lines = (line.decode(ENCODING, 'replace') for line in lines)
2528 # Limit the line length in Unicode-space to avoid
2529 # splitting characters:
2530 if self.__emailmaxlinelength:
2531 lines = limit_linelength(lines, self.__emailmaxlinelength)
2278 - lines = (line.encode(ENCODING, 'replace') for line in lines)
2532 + if not PYTHON3:
2533 + lines = (line.encode(ENCODING, 'replace') for line in lines)
2534 elif self.__emailmaxlinelength:
2535 lines = limit_linelength(lines, self.__emailmaxlinelength)
2536
@@ -2404,10 +2659,10 @@ class StaticRecipientsEnvironmentMixin(Environment):
2659 # actual *contents* of the change being reported, we only
2660 # choose based on the *type* of the change. Therefore we can
2661 # compute them once and for all:
2407 - if not (refchange_recipients
2408 - or announce_recipients
2409 - or revision_recipients
2410 - or scancommitforcc):
2662 + if not (refchange_recipients or
2663 + announce_recipients or
2664 + revision_recipients or
2665 + scancommitforcc):
2666 raise ConfigurationException('No email recipients configured!')
2667 self.__refchange_recipients = refchange_recipients
2668 self.__announce_recipients = announce_recipients
@@ -2457,13 +2712,104 @@ class ConfigRecipientsEnvironmentMixin(
2712 found, raise a ConfigurationException."""
2713
2714 for name in names:
2460 - retval = config.get_recipients(name)
2461 - if retval is not None:
2462 - return retval
2715 + lines = config.get_all(name)
2716 + if lines is not None:
2717 + lines = [line.strip() for line in lines]
2718 + # Single "none" is a special value equivalen to empty string.
2719 + if lines == ['none']:
2720 + lines = ['']
2721 + return ', '.join(lines)
2722 else:
2723 return ''
2724
2725
2726 +class StaticRefFilterEnvironmentMixin(Environment):
2727 + """Set branch filter statically based on constructor parameters."""
2728 +
2729 + def __init__(self, ref_filter_incl_regex, ref_filter_excl_regex,
2730 + ref_filter_do_send_regex, ref_filter_dont_send_regex,
2731 + **kw):
2732 + super(StaticRefFilterEnvironmentMixin, self).__init__(**kw)
2733 +
2734 + if ref_filter_incl_regex and ref_filter_excl_regex:
2735 + raise ConfigurationException(
2736 + "Cannot specify both a ref inclusion and exclusion regex.")
2737 + self.__is_inclusion_filter = bool(ref_filter_incl_regex)
2738 + default_exclude = self.get_default_ref_ignore_regex()
2739 + if ref_filter_incl_regex:
2740 + ref_filter_regex = ref_filter_incl_regex
2741 + elif ref_filter_excl_regex:
2742 + ref_filter_regex = ref_filter_excl_regex + '|' + default_exclude
2743 + else:
2744 + ref_filter_regex = default_exclude
2745 + try:
2746 + self.__compiled_regex = re.compile(ref_filter_regex)
2747 + except Exception:
2748 + raise ConfigurationException(
2749 + 'Invalid Ref Filter Regex "%s": %s' % (ref_filter_regex, sys.exc_info()[1]))
2750 +
2751 + if ref_filter_do_send_regex and ref_filter_dont_send_regex:
2752 + raise ConfigurationException(
2753 + "Cannot specify both a ref doSend and dontSend regex.")
2754 + if ref_filter_do_send_regex or ref_filter_dont_send_regex:
2755 + self.__is_do_send_filter = bool(ref_filter_do_send_regex)
2756 + if ref_filter_incl_regex:
2757 + ref_filter_send_regex = ref_filter_incl_regex
2758 + elif ref_filter_excl_regex:
2759 + ref_filter_send_regex = ref_filter_excl_regex
2760 + else:
2761 + ref_filter_send_regex = '.*'
2762 + self.__is_do_send_filter = True
2763 + try:
2764 + self.__send_compiled_regex = re.compile(ref_filter_send_regex)
2765 + except Exception:
2766 + raise ConfigurationException(
2767 + 'Invalid Ref Filter Regex "%s": %s' %
2768 + (ref_filter_send_regex, sys.exc_info()[1]))
2769 + else:
2770 + self.__send_compiled_regex = self.__compiled_regex
2771 + self.__is_do_send_filter = self.__is_inclusion_filter
2772 +
2773 + def get_ref_filter_regex(self, send_filter=False):
2774 + if send_filter:
2775 + return self.__send_compiled_regex, self.__is_do_send_filter
2776 + else:
2777 + return self.__compiled_regex, self.__is_inclusion_filter
2778 +
2779 +
2780 +class ConfigRefFilterEnvironmentMixin(
2781 + ConfigEnvironmentMixin,
2782 + StaticRefFilterEnvironmentMixin
2783 + ):
2784 + """Determine branch filtering statically based on config."""
2785 +
2786 + def _get_regex(self, config, key):
2787 + """Get a list of whitespace-separated regex. The refFilter* config
2788 + variables are multivalued (hence the use of get_all), and we
2789 + allow each entry to be a whitespace-separated list (hence the
2790 + split on each line). The whole thing is glued into a single regex."""
2791 + values = config.get_all(key)
2792 + if values is None:
2793 + return values
2794 + items = []
2795 + for line in values:
2796 + for i in line.split():
2797 + items.append(i)
2798 + if items == []:
2799 + return None
2800 + return '|'.join(items)
2801 +
2802 + def __init__(self, config, **kw):
2803 + super(ConfigRefFilterEnvironmentMixin, self).__init__(
2804 + config=config,
2805 + ref_filter_incl_regex=self._get_regex(config, 'refFilterInclusionRegex'),
2806 + ref_filter_excl_regex=self._get_regex(config, 'refFilterExclusionRegex'),
2807 + ref_filter_do_send_regex=self._get_regex(config, 'refFilterDoSendRegex'),
2808 + ref_filter_dont_send_regex=self._get_regex(config, 'refFilterDontSendRegex'),
2809 + **kw
2810 + )
2811 +
2812 +
2813 class ProjectdescEnvironmentMixin(Environment):
2814 """Make a "projectdesc" value available for templates.
2815
@@ -2499,6 +2845,7 @@ class GenericEnvironment(
2845 ComputeFQDNEnvironmentMixin,
2846 ConfigFilterLinesEnvironmentMixin,
2847 ConfigRecipientsEnvironmentMixin,
2848 + ConfigRefFilterEnvironmentMixin,
2849 PusherDomainEnvironmentMixin,
2850 ConfigOptionsEnvironmentMixin,
2851 GenericEnvironmentMixin,
@@ -2513,14 +2860,14 @@ class GitoliteEnvironmentMixin(Environment):
2860 # repo_shortname (though it's probably not as good as a value
2861 # the user might have explicitly put in his config).
2862 return (
2516 - self.osenv.get('GL_REPO', None)
2517 - or super(GitoliteEnvironmentMixin, self).get_repo_shortname()
2863 + self.osenv.get('GL_REPO', None) or
2864 + super(GitoliteEnvironmentMixin, self).get_repo_shortname()
2865 )
2866
2867 def get_pusher(self):
2868 return self.osenv.get('GL_USER', 'unknown user')
2869
2523 - def get_fromaddr(self):
2870 + def get_fromaddr(self, change=None):
2871 GL_USER = self.osenv.get('GL_USER')
2872 if GL_USER is not None:
2873 # Find the path to gitolite.conf. Note that gitolite v3
@@ -2536,9 +2883,9 @@ class GitoliteEnvironmentMixin(Environment):
2883 f = open(GL_CONF, 'rU')
2884 try:
2885 in_user_emails_section = False
2539 - re_template = r'^\s*#\s*{}\s*$'
2886 + re_template = r'^\s*#\s*%s\s*$'
2887 re_begin, re_user, re_end = (
2541 - re.compile(re_template.format(x))
2888 + re.compile(re_template % x)
2889 for x in (
2890 r'BEGIN\s+USER\s+EMAILS',
2891 re.escape(GL_USER) + r'\s+(.*)',
@@ -2557,7 +2904,7 @@ class GitoliteEnvironmentMixin(Environment):
2904 return m.group(1)
2905 finally:
2906 f.close()
2560 - return super(GitoliteEnvironmentMixin, self).get_fromaddr()
2907 + return super(GitoliteEnvironmentMixin, self).get_fromaddr(change)
2908
2909
2910 class IncrementalDateTime(object):
@@ -2570,8 +2917,9 @@ class IncrementalDateTime(object):
2917
2918 def __init__(self):
2919 self.time = time.time()
2920 + self.next = self.__next__ # Python 2 backward compatibility
2921
2574 - def next(self):
2922 + def __next__(self):
2923 formatted = formatdate(self.time, True)
2924 self.time += 1
2925 return formatted
@@ -2583,6 +2931,7 @@ class GitoliteEnvironment(
2931 ComputeFQDNEnvironmentMixin,
2932 ConfigFilterLinesEnvironmentMixin,
2933 ConfigRecipientsEnvironmentMixin,
2934 + ConfigRefFilterEnvironmentMixin,
2935 PusherDomainEnvironmentMixin,
2936 ConfigOptionsEnvironmentMixin,
2937 GitoliteEnvironmentMixin,
@@ -2591,6 +2940,117 @@ class GitoliteEnvironment(
2940 pass
2941
2942
2943 +class StashEnvironmentMixin(Environment):
2944 + def __init__(self, user=None, repo=None, **kw):
2945 + super(StashEnvironmentMixin, self).__init__(**kw)
2946 + self.__user = user
2947 + self.__repo = repo
2948 +
2949 + def get_repo_shortname(self):
2950 + return self.__repo
2951 +
2952 + def get_pusher(self):
2953 + return re.match('(.*?)\s*<', self.__user).group(1)
2954 +
2955 + def get_pusher_email(self):
2956 + return self.__user
2957 +
2958 + def get_fromaddr(self, change=None):
2959 + return self.__user
2960 +
2961 +
2962 +class StashEnvironment(
2963 + StashEnvironmentMixin,
2964 + ProjectdescEnvironmentMixin,
2965 + ConfigMaxlinesEnvironmentMixin,
2966 + ComputeFQDNEnvironmentMixin,
2967 + ConfigFilterLinesEnvironmentMixin,
2968 + ConfigRecipientsEnvironmentMixin,
2969 + ConfigRefFilterEnvironmentMixin,
2970 + PusherDomainEnvironmentMixin,
2971 + ConfigOptionsEnvironmentMixin,
2972 + Environment,
2973 + ):
2974 + pass
2975 +
2976 +
2977 +class GerritEnvironmentMixin(Environment):
2978 + def __init__(self, project=None, submitter=None, update_method=None, **kw):
2979 + super(GerritEnvironmentMixin, self).__init__(**kw)
2980 + self.__project = project
2981 + self.__submitter = submitter
2982 + self.__update_method = update_method
2983 + "Make an 'update_method' value available for templates."
2984 + self.COMPUTED_KEYS += ['update_method']
2985 +
2986 + def get_repo_shortname(self):
2987 + return self.__project
2988 +
2989 + def get_pusher(self):
2990 + if self.__submitter:
2991 + if self.__submitter.find('<') != -1:
2992 + # Submitter has a configured email, we transformed
2993 + # __submitter into an RFC 2822 string already.
2994 + return re.match('(.*?)\s*<', self.__submitter).group(1)
2995 + else:
2996 + # Submitter has no configured email, it's just his name.
2997 + return self.__submitter
2998 + else:
2999 + # If we arrive here, this means someone pushed "Submit" from
3000 + # the gerrit web UI for the CR (or used one of the programmatic
3001 + # APIs to do the same, such as gerrit review) and the
3002 + # merge/push was done by the Gerrit user. It was technically
3003 + # triggered by someone else, but sadly we have no way of
3004 + # determining who that someone else is at this point.
3005 + return 'Gerrit' # 'unknown user'?
3006 +
3007 + def get_pusher_email(self):
3008 + if self.__submitter:
3009 + return self.__submitter
3010 + else:
3011 + return super(GerritEnvironmentMixin, self).get_pusher_email()
3012 +
3013 + def get_fromaddr(self, change=None):
3014 + if self.__submitter and self.__submitter.find('<') != -1:
3015 + return self.__submitter
3016 + else:
3017 + return super(GerritEnvironmentMixin, self).get_fromaddr(change)
3018 +
3019 + def get_default_ref_ignore_regex(self):
3020 + default = super(GerritEnvironmentMixin, self).get_default_ref_ignore_regex()
3021 + return default + '|^refs/changes/|^refs/cache-automerge/|^refs/meta/'
3022 +
3023 + def get_revision_recipients(self, revision):
3024 + # Merge commits created by Gerrit when users hit "Submit this patchset"
3025 + # in the Web UI (or do equivalently with REST APIs or the gerrit review
3026 + # command) are not something users want to see an individual email for.
3027 + # Filter them out.
3028 + committer = read_git_output(['log', '--no-walk', '--format=%cN',
3029 + revision.rev.sha1])
3030 + if committer == 'Gerrit Code Review':
3031 + return []
3032 + else:
3033 + return super(GerritEnvironmentMixin, self).get_revision_recipients(revision)
3034 +
3035 + def get_update_method(self):
3036 + return self.__update_method
3037 +
3038 +
3039 +class GerritEnvironment(
3040 + GerritEnvironmentMixin,
3041 + ProjectdescEnvironmentMixin,
3042 + ConfigMaxlinesEnvironmentMixin,
3043 + ComputeFQDNEnvironmentMixin,
3044 + ConfigFilterLinesEnvironmentMixin,
3045 + ConfigRecipientsEnvironmentMixin,
3046 + ConfigRefFilterEnvironmentMixin,
3047 + PusherDomainEnvironmentMixin,
3048 + ConfigOptionsEnvironmentMixin,
3049 + Environment,
3050 + ):
3051 + pass
3052 +
3053 +
3054 class Push(object):
3055 """Represent an entire push (i.e., a group of ReferenceChanges).
3056
@@ -2673,10 +3133,11 @@ class Push(object):
3133 ])
3134 )
3135
2676 - def __init__(self, changes, ignore_other_refs=False):
3136 + def __init__(self, environment, changes, ignore_other_refs=False):
3137 self.changes = sorted(changes, key=self._sort_key)
3138 self.__other_ref_sha1s = None
3139 self.__cached_commits_spec = {}
3140 + self.environment = environment
3141
3142 if ignore_other_refs:
3143 self.__other_ref_sha1s = set()
@@ -2703,10 +3164,14 @@ class Push(object):
3164 '%(objectname) %(objecttype) %(refname)\n'
3165 '%(*objectname) %(*objecttype) %(refname)'
3166 )
3167 + ref_filter_regex, is_inclusion_filter = \
3168 + self.environment.get_ref_filter_regex()
3169 for line in read_git_lines(
3170 ['for-each-ref', '--format=%s' % (fmt,)]):
3171 (sha1, type, name) = line.split(' ', 2)
2709 - if sha1 and type == 'commit' and name not in updated_refs:
3172 + if (sha1 and type == 'commit' and
3173 + name not in updated_refs and
3174 + include_ref(name, ref_filter_regex, is_inclusion_filter)):
3175 sha1s.add(sha1)
3176
3177 self.__other_ref_sha1s = sha1s
@@ -2856,7 +3321,7 @@ class Push(object):
3321 if not change.environment.quiet:
3322 change.environment.log_msg(
3323 'Sending notification emails to: %s\n' % (change.recipients,))
2859 - extra_values = {'send_date': send_date.next()}
3324 + extra_values = {'send_date': next(send_date)}
3325
3326 rev = change.send_single_combined_email(sha1s)
3327 if rev:
@@ -2876,9 +3341,9 @@ class Push(object):
3341 max_emails = change.environment.maxcommitemails
3342 if max_emails and len(sha1s) > max_emails:
3343 change.environment.log_warning(
2879 - '*** Too many new commits (%d), not sending commit emails.\n' % len(sha1s)
2880 - + '*** Try setting multimailhook.maxCommitEmails to a greater value\n'
2881 - + '*** Currently, multimailhook.maxCommitEmails=%d\n' % max_emails
3344 + '*** Too many new commits (%d), not sending commit emails.\n' % len(sha1s) +
3345 + '*** Try setting multimailhook.maxCommitEmails to a greater value\n' +
3346 + '*** Currently, multimailhook.maxCommitEmails=%d\n' % max_emails
3347 )
3348 return
3349
@@ -2889,7 +3354,7 @@ class Push(object):
3354 rev.recipients = rev.cc_recipients
3355 rev.cc_recipients = None
3356 if rev.recipients:
2892 - extra_values = {'send_date': send_date.next()}
3357 + extra_values = {'send_date': next(send_date)}
3358 mailer.send(
3359 rev.generate_email(self, body_filter, extra_values),
3360 rev.recipients,
@@ -2904,18 +3369,33 @@ class Push(object):
3369 )
3370
3371
3372 +def include_ref(refname, ref_filter_regex, is_inclusion_filter):
3373 + does_match = bool(ref_filter_regex.search(refname))
3374 + if is_inclusion_filter:
3375 + return does_match
3376 + else: # exclusion filter -- we include the ref if the regex doesn't match
3377 + return not does_match
3378 +
3379 +
3380 def run_as_post_receive_hook(environment, mailer):
3381 + ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(True)
3382 changes = []
3383 for line in sys.stdin:
3384 (oldrev, newrev, refname) = line.strip().split(' ', 2)
3385 + if not include_ref(refname, ref_filter_regex, is_inclusion_filter):
3386 + continue
3387 changes.append(
3388 ReferenceChange.create(environment, oldrev, newrev, refname)
3389 )
2914 - push = Push(changes)
2915 - push.send_emails(mailer, body_filter=environment.filter_body)
3390 + if changes:
3391 + push = Push(environment, changes)
3392 + push.send_emails(mailer, body_filter=environment.filter_body)
3393
3394
3395 def run_as_update_hook(environment, mailer, refname, oldrev, newrev, force_send=False):
3396 + ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(True)
3397 + if not include_ref(refname, ref_filter_regex, is_inclusion_filter):
3398 + return
3399 changes = [
3400 ReferenceChange.create(
3401 environment,
@@ -2924,7 +3404,7 @@ def run_as_update_hook(environment, mailer, refname, oldrev, newrev, force_send=
3404 refname,
3405 ),
3406 ]
2927 - push = Push(changes, force_send)
3407 + push = Push(environment, changes, force_send)
3408 push.send_emails(mailer, body_filter=environment.filter_body)
3409
3410
@@ -2953,8 +3433,8 @@ def choose_mailer(config, environment):
3433 mailer = SendMailer(command=command, envelopesender=environment.get_sender())
3434 else:
3435 environment.log_error(
2956 - 'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n' % mailer
2957 - + 'please use one of "smtp" or "sendmail".\n'
3436 + 'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n' % mailer +
3437 + 'please use one of "smtp" or "sendmail".\n'
3438 )
3439 sys.exit(1)
3440 return mailer
@@ -2963,14 +3443,18 @@ def choose_mailer(config, environment):
3443 KNOWN_ENVIRONMENTS = {
3444 'generic': GenericEnvironmentMixin,
3445 'gitolite': GitoliteEnvironmentMixin,
3446 + 'stash': StashEnvironmentMixin,
3447 + 'gerrit': GerritEnvironmentMixin,
3448 }
3449
3450
2969 -def choose_environment(config, osenv=None, env=None, recipients=None):
3451 +def choose_environment(config, osenv=None, env=None, recipients=None,
3452 + hook_info=None):
3453 if not osenv:
3454 osenv = os.environ
3455
3456 environment_mixins = [
3457 + ConfigRefFilterEnvironmentMixin,
3458 ProjectdescEnvironmentMixin,
3459 ConfigMaxlinesEnvironmentMixin,
3460 ComputeFQDNEnvironmentMixin,
@@ -2992,7 +3476,15 @@ def choose_environment(config, osenv=None, env=None, recipients=None):
3476 else:
3477 env = 'generic'
3478
2995 - environment_mixins.append(KNOWN_ENVIRONMENTS[env])
3479 + environment_mixins.insert(0, KNOWN_ENVIRONMENTS[env])
3480 +
3481 + if env == 'stash':
3482 + environment_kw['user'] = hook_info['stash_user']
3483 + environment_kw['repo'] = hook_info['stash_repo']
3484 + elif env == 'gerrit':
3485 + environment_kw['project'] = hook_info['project']
3486 + environment_kw['submitter'] = hook_info['submitter']
3487 + environment_kw['update_method'] = hook_info['update_method']
3488
3489 if recipients:
3490 environment_mixins.insert(0, StaticRecipientsEnvironmentMixin)
@@ -3011,6 +3503,116 @@ def choose_environment(config, osenv=None, env=None, recipients=None):
3503 return environment_klass(**environment_kw)
3504
3505
3506 +def get_version():
3507 + oldcwd = os.getcwd()
3508 + try:
3509 + try:
3510 + os.chdir(os.path.dirname(os.path.realpath(__file__)))
3511 + git_version = read_git_output(['describe', '--tags', 'HEAD'])
3512 + if git_version == __version__:
3513 + return git_version
3514 + else:
3515 + return '%s (%s)' % (__version__, git_version)
3516 + except:
3517 + pass
3518 + finally:
3519 + os.chdir(oldcwd)
3520 + return __version__
3521 +
3522 +
3523 +def compute_gerrit_options(options, args, required_gerrit_options):
3524 + if None in required_gerrit_options:
3525 + raise SystemExit("Error: Specify all of --oldrev, --newrev, --refname, "
3526 + "and --project; or none of them.")
3527 +
3528 + if options.environment not in (None, 'gerrit'):
3529 + raise SystemExit("Non-gerrit environments incompatible with --oldrev, "
3530 + "--newrev, --refname, and --project")
3531 + options.environment = 'gerrit'
3532 +
3533 + if args:
3534 + raise SystemExit("Error: Positional parameters not allowed with "
3535 + "--oldrev, --newrev, and --refname.")
3536 +
3537 + # Gerrit oddly omits 'refs/heads/' in the refname when calling
3538 + # ref-updated hook; put it back.
3539 + git_dir = get_git_dir()
3540 + if (not os.path.exists(os.path.join(git_dir, options.refname)) and
3541 + os.path.exists(os.path.join(git_dir, 'refs', 'heads',
3542 + options.refname))):
3543 + options.refname = 'refs/heads/' + options.refname
3544 +
3545 + # Convert each string option unicode for Python3.
3546 + if PYTHON3:
3547 + opts = ['environment', 'recipients', 'oldrev', 'newrev', 'refname',
3548 + 'project', 'submitter', 'stash-user', 'stash-repo']
3549 + for opt in opts:
3550 + if not hasattr(options, opt):
3551 + continue
3552 + obj = getattr(options, opt)
3553 + if obj:
3554 + enc = obj.encode('utf-8', 'surrogateescape')
3555 + dec = enc.decode('utf-8', 'replace')
3556 + setattr(options, opt, dec)
3557 +
3558 + # New revisions can appear in a gerrit repository either due to someone
3559 + # pushing directly (in which case options.submitter will be set), or they
3560 + # can press "Submit this patchset" in the web UI for some CR (in which
3561 + # case options.submitter will not be set and gerrit will not have provided
3562 + # us the information about who pressed the button).
3563 + #
3564 + # Note for the nit-picky: I'm lumping in REST API calls and the ssh
3565 + # gerrit review command in with "Submit this patchset" button, since they
3566 + # have the same effect.
3567 + if options.submitter:
3568 + update_method = 'pushed'
3569 + # The submitter argument is almost an RFC 2822 email address; change it
3570 + # from 'User Name (email@domain)' to 'User Name <email@domain>' so it is
3571 + options.submitter = options.submitter.replace('(', '<').replace(')', '>')
3572 + else:
3573 + update_method = 'submitted'
3574 + # Gerrit knew who submitted this patchset, but threw that information
3575 + # away when it invoked this hook. However, *IF* Gerrit created a
3576 + # merge to bring the patchset in (project 'Submit Type' is either
3577 + # "Always Merge", or is "Merge if Necessary" and happens to be
3578 + # necessary for this particular CR), then it will have the committer
3579 + # of that merge be 'Gerrit Code Review' and the author will be the
3580 + # person who requested the submission of the CR. Since this is fairly
3581 + # likely for most gerrit installations (of a reasonable size), it's
3582 + # worth the extra effort to try to determine the actual submitter.
3583 + rev_info = read_git_lines(['log', '--no-walk', '--merges',
3584 + '--format=%cN%n%aN <%aE>', options.newrev])
3585 + if rev_info and rev_info[0] == 'Gerrit Code Review':
3586 + options.submitter = rev_info[1]
3587 +
3588 + # We pass back refname, oldrev, newrev as args because then the
3589 + # gerrit ref-updated hook is much like the git update hook
3590 + return (options,
3591 + [options.refname, options.oldrev, options.newrev],
3592 + {'project': options.project, 'submitter': options.submitter,
3593 + 'update_method': update_method})
3594 +
3595 +
3596 +def check_hook_specific_args(options, args):
3597 + # First check for stash arguments
3598 + if (options.stash_user is None) != (options.stash_repo is None):
3599 + raise SystemExit("Error: Specify both of --stash-user and "
3600 + "--stash-repo or neither.")
3601 + if options.stash_user:
3602 + options.environment = 'stash'
3603 + return options, args, {'stash_user': options.stash_user,
3604 + 'stash_repo': options.stash_repo}
3605 +
3606 + # Finally, check for gerrit specific arguments
3607 + required_gerrit_options = (options.oldrev, options.newrev, options.refname,
3608 + options.project)
3609 + if required_gerrit_options != (None,) * 4:
3610 + return compute_gerrit_options(options, args, required_gerrit_options)
3611 +
3612 + # No special options in use, just return what we started with
3613 + return options, args, {}
3614 +
3615 +
3616 def main(args):
3617 parser = optparse.OptionParser(
3618 description=__doc__,
@@ -3019,7 +3621,7 @@ def main(args):
3621
3622 parser.add_option(
3623 '--environment', '--env', action='store', type='choice',
3022 - choices=['generic', 'gitolite'], default=None,
3624 + choices=list(KNOWN_ENVIRONMENTS.keys()), default=None,
3625 help=(
3626 'Choose type of environment is in use. Default is taken from '
3627 'multimailhook.environment if set; otherwise "generic".'
@@ -3048,8 +3650,58 @@ def main(args):
3650 'detection in this mode.'
3651 ),
3652 )
3653 + parser.add_option(
3654 + '-c', metavar="<name>=<value>", action='append',
3655 + help=(
3656 + 'Pass a configuration parameter through to git. The value given '
3657 + 'will override values from configuration files. See the -c option '
3658 + 'of git(1) for more details. (Only works with git >= 1.7.3)'
3659 + ),
3660 + )
3661 + parser.add_option(
3662 + '--version', '-v', action='store_true', default=False,
3663 + help=(
3664 + "Display git-multimail's version"
3665 + ),
3666 + )
3667 + # The following options permit this script to be run as a gerrit
3668 + # ref-updated hook. See e.g.
3669 + # code.google.com/p/gerrit/source/browse/Documentation/config-hooks.txt
3670 + # We suppress help for these items, since these are specific to gerrit,
3671 + # and we don't want users directly using them any way other than how the
3672 + # gerrit ref-updated hook is called.
3673 + parser.add_option('--oldrev', action='store', help=optparse.SUPPRESS_HELP)
3674 + parser.add_option('--newrev', action='store', help=optparse.SUPPRESS_HELP)
3675 + parser.add_option('--refname', action='store', help=optparse.SUPPRESS_HELP)
3676 + parser.add_option('--project', action='store', help=optparse.SUPPRESS_HELP)
3677 + parser.add_option('--submitter', action='store', help=optparse.SUPPRESS_HELP)
3678 +
3679 + # The following allow this to be run as a stash asynchronous post-receive
3680 + # hook (almost identical to a git post-receive hook but triggered also for
3681 + # merges of pull requests from the UI). We suppress help for these items,
3682 + # since these are specific to stash.
3683 + parser.add_option('--stash-user', action='store', help=optparse.SUPPRESS_HELP)
3684 + parser.add_option('--stash-repo', action='store', help=optparse.SUPPRESS_HELP)
3685
3686 (options, args) = parser.parse_args(args)
3687 + (options, args, hook_info) = check_hook_specific_args(options, args)
3688 +
3689 + if options.version:
3690 + sys.stdout.write('git-multimail version ' + get_version() + '\n')
3691 + return
3692 +
3693 + if options.c:
3694 + parameters = os.environ.get('GIT_CONFIG_PARAMETERS', '')
3695 + if parameters:
3696 + parameters += ' '
3697 + # git expects GIT_CONFIG_PARAMETERS to be of the form
3698 + # "'name1=value1' 'name2=value2' 'name3=value3'"
3699 + # including everything inside the double quotes (but not the double
3700 + # quotes themselves). Spacing is critical. Also, if a value contains
3701 + # a literal single quote that quote must be represented using the
3702 + # four character sequence: '\''
3703 + parameters += ' '.join("'" + x.replace("'", "'\\''") + "'" for x in options.c)
3704 + os.environ['GIT_CONFIG_PARAMETERS'] = parameters
3705
3706 config = Config('multimailhook')
3707
@@ -3058,6 +3710,7 @@ def main(args):
3710 config, osenv=os.environ,
3711 env=options.environment,
3712 recipients=options.recipients,
3713 + hook_info=hook_info,
3714 )
3715
3716 if options.show_env:
@@ -3080,9 +3733,20 @@ def main(args):
3733 run_as_update_hook(environment, mailer, refname, oldrev, newrev, options.force_send)
3734 else:
3735 run_as_post_receive_hook(environment, mailer)
3083 - except ConfigurationException, e:
3084 - sys.exit(str(e))
3085 -
3736 + except ConfigurationException:
3737 + sys.exit(sys.exc_info()[1])
3738 + except Exception:
3739 + t, e, tb = sys.exc_info()
3740 + import traceback
3741 + sys.stdout.write('\n')
3742 + sys.stdout.write('Exception \'' + t.__name__ +
3743 + '\' raised. Please report this as a bug to\n')
3744 + sys.stdout.write('https://github.com/git-multimail/git-multimail/issues\n')
3745 + sys.stdout.write('with the information below:\n\n')
3746 + sys.stdout.write('git-multimail version ' + get_version() + '\n')
3747 + sys.stdout.write('Python version ' + sys.version + '\n')
3748 + traceback.print_exc(file=sys.stdout)
3749 + sys.exit(1)
3750
3751 if __name__ == '__main__':
3752 main(sys.argv[1:])
contrib/hooks/multimail/migrate-mailhook-config
+1 -1
@@ -1,4 +1,4 @@
1 -#! /usr/bin/env python2
1 +#! /usr/bin/env python
2
3 """Migrate a post-receive-email configuration to be usable with git_multimail.py.
4
contrib/hooks/multimail/post-receive.example
+4 -4
@@ -1,4 +1,4 @@
1 -#! /usr/bin/env python2
1 +#! /usr/bin/env python
2
3 """Example post-receive hook based on git-multimail.
4
@@ -42,7 +42,6 @@ import os
42
43 import git_multimail
44
45 -
45 # It is possible to modify the output templates here; e.g.:
46
47 #git_multimail.FOOTER_TEMPLATE = """\
@@ -61,8 +60,9 @@ config = git_multimail.Config('multimailhook')
60 try:
61 environment = git_multimail.GenericEnvironment(config=config)
62 #environment = git_multimail.GitoliteEnvironment(config=config)
64 -except git_multimail.ConfigurationException, e:
65 - sys.exit(str(e))
63 +except git_multimail.ConfigurationException:
64 + sys.stderr.write('*** %s\n' % sys.exc_info()[1])
65 + sys.exit(1)
66
67
68 # Choose the method of sending emails based on the git config: