git-multimail: update to release 1.1.0

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>

Matthieu Moy committed Jun 18, 2015 at 10:46 UTC 5b1d901c0173b41010856e30776c92d02987ea25
6 files changed +1017 -326
contrib/hooks/multimail/CHANGES
+48
@@ -1,3 +1,51 @@
1 +Release 1.1.0
2 +=============
3 +
4 +* When a single commit is pushed, omit the reference changed email.
5 + Set multimailhook.combineWhenSingleCommit to false to disable this
6 + new feature.
7 +
8 +* In gitolite environments, the pusher's email address can be used as
9 + the From address by creating a specially formatted comment block in
10 + gitolite.conf (see multimailhook.from in README).
11 +
12 +* Support for SMTP authentication and SSL/TLS encryption was added,
13 + see smtpUser, smtpPass, smtpEncryption in README.
14 +
15 +* A new option scanCommitForCc was added to allow git-multimail to
16 + search the commit message for 'Cc: ...' lines, and add the
17 + corresponding emails in Cc.
18 +
19 +* If $USER is not set, use the variable $USERNAME. This is needed on
20 + Windows platform to recognize the pusher.
21 +
22 +* The emailPrefix variable can now be set to an empty string to remove
23 + the prefix.
24 +
25 +* A short tutorial was added in doc/gitolite.rst to set up
26 + git-multimail with gitolite.
27 +
28 +* The post-receive file was renamed to post-receive.example. It has
29 + always been an example (the standard way to call git-multimail is to
30 + call git_multimail.py), but it was unclear to many users.
31 +
32 +* A new refchangeShowGraph option was added to make it possible to
33 + include both a graph and a log in the summary emails. The options
34 + to control the graph formatting can be set via the new graphOpts
35 + option.
36 +
37 +* New option --force-send was added to disable new commit detection
38 + for update hook. One use-case is to run git_multimail.py after
39 + running "git fetch" to send emails about commits that have just been
40 + fetched (the detection of new commits was unreliable in this mode).
41 +
42 +* The testing infrastructure was considerably improved (continuous
43 + integration with travis-ci, automatic check of PEP8 and RST syntax,
44 + many improvements to the test scripts).
45 +
46 +This version has been tested with Python 2.4 to 2.7, and Git 1.7.1 to
47 +2.4.
48 +
49 Release 1.0.0
50 =============
51
contrib/hooks/multimail/README
+186 -93
@@ -1,5 +1,8 @@
1 - git-multimail
2 - =============
1 +git-multimail Version 1.1.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
6
7 git-multimail is a tool for sending notification emails on pushes to a
8 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:
41 list) makes it easy to scan through the emails, jump to patches
42 that need further attention, and write comments about specific
43 commits. Commits are handled in reverse topological order (i.e.,
41 - parents shown before children). For example,
42 -
43 - [git] branch master updated
44 - + [git] 01/08: doc: fix xref link from api docs to manual pages
45 - + [git] 02/08: api-credentials.txt: show the big picture first
46 - + [git] 03/08: api-credentials.txt: mention credential.helper explicitly
47 - + [git] 04/08: api-credentials.txt: add "see also" section
48 - + [git] 05/08: t3510 (cherry-pick-sequence): add missing '&&'
49 - + [git] 06/08: Merge branch 'rr/maint-t3510-cascade-fix'
50 - + [git] 07/08: Merge branch 'mm/api-credentials-doc'
51 - + [git] 08/08: Git 1.7.11-rc2
44 + parents shown before children). For example::
45 +
46 + [git] branch master updated
47 + + [git] 01/08: doc: fix xref link from api docs to manual pages
48 + + [git] 02/08: api-credentials.txt: show the big picture first
49 + + [git] 03/08: api-credentials.txt: mention credential.helper explicitly
50 + + [git] 04/08: api-credentials.txt: add "see also" section
51 + + [git] 05/08: t3510 (cherry-pick-sequence): add missing '&&'
52 + + [git] 06/08: Merge branch 'rr/maint-t3510-cascade-fix'
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
@@ -74,19 +77,19 @@ Requirements
77 3.x.
78
79 The example scripts invoke Python using the following shebang line
77 - (following PEP 394 [1]):
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
82 - called "python2", you can change the lines accordingly. Or you can
85 + called ``python2``, you can change the lines accordingly. Or you can
86 invoke the Python interpreter explicitly, for example via a tiny
84 - shell script like
87 + shell script like::
88
89 #! /bin/sh
90 /usr/local/bin/python /path/to/git_multimail.py "$@"
91
89 -* The "git" command must be in your PATH. git-multimail is known to
92 +* The ``git`` command must be in your PATH. git-multimail is known to
93 work with Git versions back to 1.7.1. (Earlier versions have not
94 been tested; if you do so, please report your results.)
95
@@ -101,7 +104,7 @@ Requirements
104 Invocation
105 ----------
106
104 -git_multimail.py is designed to be used as a "post-receive" hook in a
107 +git_multimail.py is designed to be used as a ``post-receive`` hook in a
108 Git repository (see githooks(5)). Link or copy it to
109 $GIT_DIR/hooks/post-receive within the repository for which email
110 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
112 pushed.
113
114 For use on pre-v1.5.1 Git servers, git_multimail.py can also work as
112 -an "update" hook, taking its arguments on the command line. To use
115 +an ``update`` hook, taking its arguments on the command line. To use
116 this script in this manner, link or copy it to $GIT_DIR/hooks/update.
117 Please note that the script is not completely reliable in this mode
115 -[2].
118 +[2]_.
119
120 Alternatively, git_multimail.py can be imported as a Python module
121 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
132 only about changes affecting particular files or subdirectories)
133
134 Or you can change how emails are sent by writing your own Mailer
132 -class. The "post-receive" script in this directory demonstrates how
135 +class. The ``post-receive`` script in this directory demonstrates how
136 to use git_multimail.py as a Python module. (If you make interesting
137 changes of this type, please consider sharing them with the
138 community.)
@@ -139,18 +142,26 @@ Configuration
142 -------------
143
144 By default, git-multimail mostly takes its configuration from the
142 -following "git config" settings:
145 +following ``git config`` settings:
146
147 multimailhook.environment
148
149 This describes the general environment of the repository.
150 Currently supported values:
151
149 - "generic" -- the username of the pusher is read from $USER and the
150 - repository name is derived from the repository's path.
152 + * generic
153 +
154 + the username of the pusher is read from $USER or $USERNAME and
155 + the repository name is derived from the repository's path.
156 +
157 + * gitolite
158
152 - "gitolite" -- the username of the pusher is read from $GL_USER and
153 - the repository name from $GL_REPO.
159 + the username of the pusher is read from $GL_USER, the repository
160 + name is read from $GL_REPO, and the From: header value is
161 + optionally read from gitolite.conf (see multimailhook.from).
162 +
163 + For more information about gitolite and git-multimail, read
164 + doc/gitolite.rst
165
166 If neither of these environments is suitable for your setup, then
167 you can implement a Python class that inherits from Environment
@@ -160,8 +171,8 @@ multimailhook.environment
171 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
163 - "gitolite" if the environment contains variables $GL_USER and
164 - $GL_REPO; otherwise "generic".
174 + ``gitolite`` if the environment contains variables $GL_USER and
175 + $GL_REPO; otherwise ``generic``.
176
177 multimailhook.repoName
178
@@ -219,61 +230,109 @@ multimailhook.announceShortlog
230 not so straightforward, then the shortlog might be confusing
231 rather than useful. Default is false.
232
233 +multimailhook.refchangeShowGraph
234 +
235 + If this option is set to true, then summary emails about reference
236 + changes will additionally include:
237 +
238 + * a graph of the added commits (if any)
239 +
240 + * a graph of the discarded commits (if any)
241 +
242 + The log is generated by running ``git log --graph`` with the options
243 + specified in graphOpts. The default is false.
244 +
245 multimailhook.refchangeShowLog
246
247 If this option is set to true, then summary emails about reference
248 changes will include a detailed log of the added commits in
249 addition to the one line summary. The log is generated by running
227 - "git log" with the options specified in multimailhook.logOpts.
250 + ``git log`` with the options specified in multimailhook.logOpts.
251 Default is false.
252
253 multimailhook.mailer
254
255 This option changes the way emails are sent. Accepted values are:
256
234 - - sendmail (the default): use the command /usr/sbin/sendmail or
235 - /usr/lib/sendmail (or sendmailCommand, if configured). This
257 + - sendmail (the default): use the command ``/usr/sbin/sendmail`` or
258 + ``/usr/lib/sendmail`` (or sendmailCommand, if configured). This
259 mode can be further customized via the following options:
260
238 - multimailhook.sendmailCommand
261 + * multimailhook.sendmailCommand
262
240 - The command used by mailer "sendmail" to send emails. Shell
241 - quoting is allowed in the value of this setting, but remember that
242 - Git requires double-quotes to be escaped; e.g.,
263 + The command used by mailer ``sendmail`` to send emails. Shell
264 + quoting is allowed in the value of this setting, but remember that
265 + Git requires double-quotes to be escaped; e.g.::
266
267 git config multimailhook.sendmailcommand '/usr/sbin/sendmail -oi -t -F \"Git Repo\"'
268
246 - Default is '/usr/sbin/sendmail -oi -t' or
247 - '/usr/lib/sendmail -oi -t' (depending on which file is
248 - present and executable).
269 + Default is '/usr/sbin/sendmail -oi -t' or
270 + '/usr/lib/sendmail -oi -t' (depending on which file is
271 + present and executable).
272
250 - multimailhook.envelopeSender
273 + * multimailhook.envelopeSender
274
252 - If set then pass this value to sendmail via the -f option to set
253 - the envelope sender address.
275 + If set then pass this value to sendmail via the -f option to set
276 + the envelope sender address.
277
278 - smtp: use Python's smtplib. This is useful when the sendmail
279 command is not available on the system. This mode can be
280 further customized via the following options:
281
259 - multimailhook.smtpServer
282 + * multimailhook.smtpServer
283 +
284 + The name of the SMTP server to connect to. The value can
285 + also include a colon and a port number; e.g.,
286 + ``mail.example.com:25``. Default is 'localhost' using port 25.
287 +
288 + * multimailhook.smtpUser
289 + * multimailhook.smtpPass
290 +
291 + Server username and password. Required if smtpEncryption is 'ssl'.
292 + Note that the username and password currently need to be
293 + set cleartext in the configuration file, which is not
294 + recommended. If you need to use this option, be sure your
295 + configuration file is read-only.
296 +
297 + * multimailhook.envelopeSender
298 +
299 + The sender address to be passed to the SMTP server. If
300 + unset, then the value of multimailhook.from is used.
301 +
302 + * multimailhook.smtpServerTimeout
303 +
304 + Timeout in seconds.
305
261 - The name of the SMTP server to connect to. The value can
262 - also include a colon and a port number; e.g.,
263 - "mail.example.com:25". Default is 'localhost' using port
264 - 25.
306 + * multimailhook.smtpEncryption
307
266 - multimailhook.envelopeSender
308 + Set the security type. Allowed values: none, ssl.
309 + Default=none.
310
268 - The sender address to be passed to the SMTP server. If
269 - unset, then the value of multimailhook.from is used.
311 + * multimailhook.smtpServerDebugLevel
312 +
313 + Integer number. Set to greater than 0 to activate debugging.
314
315 multimailhook.from
316
273 - If set then use this value in the From: field of generated emails.
274 - If unset, then use the repository's user configuration (user.name
275 - and user.email). If user.email is also unset, then use
276 - multimailhook.envelopeSender.
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:
319 +
320 + 1. (gitolite environment only) Parse gitolite.conf, looking for a
321 + block of comments that looks like this::
322 +
323 + # BEGIN USER EMAILS
324 + # username Firstname Lastname <email@example.com>
325 + # END USER EMAILS
326 +
327 + If that block exists, and there is a line between the BEGIN
328 + USER EMAILS and END USER EMAILS lines where the first field
329 + matches the gitolite username ($GL_USER), use the rest of the
330 + line for the From: header.
331 +
332 + 2. If the user.email configuration setting is set, use its value
333 + (and the value of user.name, if set).
334 +
335 + 3. Use the value of multimailhook.envelopeSender.
336
337 multimailhook.administrator
338
@@ -287,7 +346,8 @@ multimailhook.emailPrefix
346 All emails have this string prepended to their subjects, to aid
347 email filtering (though filtering based on the X-Git-* email
348 headers is probably more robust). Default is the short name of
290 - the repository in square brackets; e.g., "[myrepo]".
349 + the repository in square brackets; e.g., ``[myrepo]``. Set this
350 + value to the empty string to suppress the email prefix.
351
352 multimailhook.emailMaxLines
353
@@ -299,7 +359,7 @@ multimailhook.emailMaxLines
359 multimailhook.emailMaxLineLength
360
361 The maximum length of a line in the email body. Lines longer than
302 - this limit are truncated to this length with a trailing " [...]"
362 + this limit are truncated to this length with a trailing `` [...]``
363 added to indicate the missing text. The default is 500, because
364 (a) diffs with longer lines are probably from binary files, for
365 which a diff is useless, and (b) even if a text file has such long
@@ -316,54 +376,62 @@ multimailhook.maxCommitEmails
376
377 multimailhook.emailStrictUTF8
378
319 - If this boolean option is set to "true", then the main part of the
379 + If this boolean option is set to `true`, then the main part of the
380 email body is forced to be valid UTF-8. Any characters that are
381 not valid UTF-8 are converted to the Unicode replacement
322 - character, U+FFFD. The default is "true".
382 + character, U+FFFD. The default is `true`.
383
384 multimailhook.diffOpts
385
326 - Options passed to "git diff-tree" when generating the summary
327 - information for ReferenceChange emails. Default is "--stat
328 - --summary --find-copies-harder". Add -p to those options to
386 + Options passed to ``git diff-tree`` when generating the summary
387 + information for ReferenceChange emails. Default is ``--stat
388 + --summary --find-copies-harder``. Add -p to those options to
389 include a unified diff of changes in addition to the usual summary
390 output. Shell quoting is allowed; see multimailhook.logOpts for
391 details.
392
393 +multimailhook.graphOpts
394 +
395 + Options passed to ``git log --graph`` when generating graphs for the
396 + reference change summary emails (used only if refchangeShowGraph
397 + is true). The default is '--oneline --decorate'.
398 +
399 + Shell quoting is allowed; see logOpts for details.
400 +
401 multimailhook.logOpts
402
335 - Options passed to "git log" to generate additional info for
403 + Options passed to ``git log`` to generate additional info for
404 reference change emails (used only if refchangeShowLog is set).
337 - For example, adding --graph will show the graph of revisions, -p
338 - will show the complete diff, etc. The default is empty.
405 + For example, adding -p will show each commit's complete diff. The
406 + default is empty.
407
408 Shell quoting is allowed; for example, a log format that contains
341 - spaces can be specified using something like:
409 + spaces can be specified using something like::
410
411 git config multimailhook.logopts '--pretty=format:"%h %aN <%aE>%n%s%n%n%b%n"'
412
413 If you want to set this by editing your configuration file
414 directly, remember that Git requires double-quotes to be escaped
347 - (see git-config(1) for more information):
415 + (see git-config(1) for more information)::
416
417 [multimailhook]
418 logopts = --pretty=format:\"%h %aN <%aE>%n%s%n%n%b%n\"
419
420 multimailhook.commitLogOpts
421
354 - Options passed to "git log" to generate additional info for
422 + Options passed to ``git log`` to generate additional info for
423 revision change emails. For example, adding --ignore-all-spaces
356 - will suppress whitespace changes. The default options are "-C
357 - --stat -p --cc". Shell quoting is allowed; see
424 + will suppress whitespace changes. The default options are ``-C
425 + --stat -p --cc``. Shell quoting is allowed; see
426 multimailhook.logOpts for details.
427
428 multimailhook.emailDomain
429
430 Domain name appended to the username of the person doing the push
363 - to convert it into an email address (via "%s@%s" % (username,
364 - emaildomain)). More complicated schemes can be implemented by
365 - overriding Environment and overriding its get_pusher_email()
366 - method.
431 + to convert it into an email address
432 + (via ``"%s@%s" % (username, emaildomain)``). More complicated
433 + schemes can be implemented by overriding Environment and
434 + overriding its get_pusher_email() method.
435
436 multimailhook.replyTo
437 multimailhook.replyToCommit
@@ -377,26 +445,48 @@ multimailhook.replyToRefchange
445
446 - An email address, which will be used directly.
447
380 - - The value "pusher", in which case the pusher's address (if
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.
451
384 - - The value "author" (meaningful only for replyToCommit), in which
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
388 - - The value "none", in which case the Reply-To: field will be
456 + - The value `none`, in which case the Reply-To: field will be
457 omitted.
458
459 +multimailhook.quiet
460 +
461 + Do not output the list of email recipients from the hook
462 +
463 +multimailhook.stdout
464 +
465 + For debugging, send emails to stdout rather than to the
466 + mailer. Equivalent to the --stdout command line option
467 +
468 +multimailhook.scanCommitForCc
469 +
470 + If this option is set to true, than recipients from lines in commit body
471 + that starts with ``CC:`` will be added to CC list.
472 + Default: false
473 +
474 +multimailhook.combineWhenSingleCommit
475 +
476 + If this option is set to true and a single new commit is pushed to
477 + a branch, combine the summary and commit email messages into a
478 + single email.
479 + Default: true
480 +
481
482 Email filtering aids
483 --------------------
484
485 All emails include extra headers to enable fine tuned filtering and
486 give information for debugging. All emails include the headers
397 -"X-Git-Host", "X-Git-Repo", "X-Git-Refname", and "X-Git-Reftype".
398 -ReferenceChange emails also include headers "X-Git-Oldrev" and "X-Git-Newrev";
399 -Revision emails also include header "X-Git-Rev".
487 +``X-Git-Host``, ``X-Git-Repo``, ``X-Git-Refname``, and ``X-Git-Reftype``.
488 +ReferenceChange emails also include headers ``X-Git-Oldrev`` and ``X-Git-Newrev``;
489 +Revision emails also include header ``X-Git-Rev``.
490
491
492 Customizing email contents
@@ -420,16 +510,17 @@ environment are built in:
510 * GenericEnvironment: a stand-alone Git repository.
511
512 * GitoliteEnvironment: a Git repository that is managed by gitolite
423 - [3]. For such repositories, the identity of the pusher is read from
424 - environment variable $GL_USER, and the name of the repository is
425 - read from $GL_REPO (if it is not overridden by
426 - multimailhook.reponame).
513 + [3]_. For such repositories, the identity of the pusher is read from
514 + environment variable $GL_USER, the name of the repository is read
515 + from $GL_REPO (if it is not overridden by multimailhook.reponame),
516 + and the From: header value is optionally read from gitolite.conf
517 + (see multimailhook.from).
518
519 By default, git-multimail assumes GitoliteEnvironment if $GL_USER and
520 $GL_REPO are set, and otherwise assumes GenericEnvironment.
521 Alternatively, you can choose one of these two environments explicitly
431 -by setting a "multimailhook.environment" config setting (which can
432 -have the value "generic" or "gitolite") or by passing an --environment
522 +by setting a ``multimailhook.environment`` config setting (which can
523 +have the value `generic` or `gitolite`) or by passing an --environment
524 option to the script.
525
526 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
530 post-receive script. Then implement your environment class; it should
531 usually inherit from one of the existing Environment classes and
532 possibly one or more of the EnvironmentMixin classes. Then set the
442 -"environment" variable to an instance of your own environment class
443 -and pass it to run_as_post_receive_hook().
533 +``environment`` variable to an instance of your own environment class
534 +and pass it to ``run_as_post_receive_hook()``.
535
536 The standard environment classes, GenericEnvironment and
537 GitoliteEnvironment, are in fact themselves put together out of a
@@ -490,12 +581,14 @@ don't overlook them.
581 Footnotes
582 ---------
583
493 -[1] http://www.python.org/dev/peps/pep-0394/
584 +.. [1] http://www.python.org/dev/peps/pep-0394/
585
495 -[2] Because of the way information is passed to update hooks, the
496 - script's method of determining whether a commit has already been
497 - seen does not work when it is used as an "update" script. In
498 - particular, no notification email will be generated for a new
499 - commit that is added to multiple references in the same push.
586 +.. [2] Because of the way information is passed to update hooks, the
587 + script's method of determining whether a commit has already
588 + been seen does not work when it is used as an ``update`` script.
589 + In particular, no notification email will be generated for a
590 + new commit that is added to multiple references in the same
591 + push. A workaround is to use --force-send to force sending the
592 + emails.
593
501 -[3] https://github.com/sitaramc/gitolite
594 +.. [3] https://github.com/sitaramc/gitolite
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 2015-04-27 and consists of the "git-multimail" subdirectory from
9 +on Jun 18 2015 and consists of the "git-multimail" subdirectory from
10 revision
11
12 - 8c3aaafa873bf10de8dddf1d202c449b3eff3b42 refs/tags/1.0.2
12 + 1f0dbb3b60035767889b913df16d9231ecdb8709 refs/tags/1.1.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/git_multimail.py
+755 -212
@@ -1,5 +1,6 @@
1 #! /usr/bin/env python2
2
3 +# Copyright (c) 2015 Matthieu Moy and others
4 # Copyright (c) 2012-2014 Michael Haggerty and others
5 # Derived from contrib/hooks/post-receive-email, which is
6 # Copyright (c) 2007 Andy Parkins
@@ -99,6 +100,10 @@ REF_DELETED_SUBJECT_TEMPLATE = (
100 ' (was %(oldrev_short)s)'
101 )
102
103 +COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE = (
104 + '%(emailprefix)s%(refname_type)s %(short_refname)s updated: %(oneline)s'
105 + )
106 +
107 REFCHANGE_HEADER_TEMPLATE = """\
108 Date: %(send_date)s
109 To: %(recipients)s
@@ -230,6 +235,7 @@ how to provide full information about this reference change.
235 REVISION_HEADER_TEMPLATE = """\
236 Date: %(send_date)s
237 To: %(recipients)s
238 +Cc: %(cc_recipients)s
239 Subject: %(emailprefix)s%(num)02d/%(tot)02d: %(oneline)s
240 MIME-Version: 1.0
241 Content-Type: text/plain; charset=%(charset)s
@@ -258,6 +264,38 @@ in repository %(repo_shortname)s.
264 REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE
265
266
267 +# Combined, meaning refchange+revision email (for single-commit additions)
268 +COMBINED_HEADER_TEMPLATE = """\
269 +Date: %(send_date)s
270 +To: %(recipients)s
271 +Subject: %(subject)s
272 +MIME-Version: 1.0
273 +Content-Type: text/plain; charset=%(charset)s
274 +Content-Transfer-Encoding: 8bit
275 +Message-ID: %(msgid)s
276 +From: %(fromaddr)s
277 +Reply-To: %(reply_to)s
278 +X-Git-Host: %(fqdn)s
279 +X-Git-Repo: %(repo_shortname)s
280 +X-Git-Refname: %(refname)s
281 +X-Git-Reftype: %(refname_type)s
282 +X-Git-Oldrev: %(oldrev)s
283 +X-Git-Newrev: %(newrev)s
284 +X-Git-Rev: %(rev)s
285 +Auto-Submitted: auto-generated
286 +"""
287 +
288 +COMBINED_INTRO_TEMPLATE = """\
289 +This is an automated email from the git hooks/post-receive script.
290 +
291 +%(pusher)s pushed a commit to %(refname_type)s %(short_refname)s
292 +in repository %(repo_shortname)s.
293 +
294 +"""
295 +
296 +COMBINED_FOOTER_TEMPLATE = FOOTER_TEMPLATE
297 +
298 +
299 class CommandError(Exception):
300 def __init__(self, cmd, retcode):
301 self.cmd = cmd
@@ -336,6 +374,47 @@ def read_git_lines(args, keepends=False, **kw):
374 return read_git_output(args, keepends=True, **kw).splitlines(keepends)
375
376
377 +def git_rev_list_ish(cmd, spec, args=None, **kw):
378 + """Common functionality for invoking a 'git rev-list'-like command.
379 +
380 + Parameters:
381 + * cmd is the Git command to run, e.g., 'rev-list' or 'log'.
382 + * spec is a list of revision arguments to pass to the named
383 + command. If None, this function returns an empty list.
384 + * args is a list of extra arguments passed to the named command.
385 + * All other keyword arguments (if any) are passed to the
386 + underlying read_git_lines() function.
387 +
388 + Return the output of the Git command in the form of a list, one
389 + entry per output line.
390 + """
391 + if spec is None:
392 + return []
393 + if args is None:
394 + args = []
395 + args = [cmd, '--stdin'] + args
396 + spec_stdin = ''.join(s + '\n' for s in spec)
397 + return read_git_lines(args, input=spec_stdin, **kw)
398 +
399 +
400 +def git_rev_list(spec, **kw):
401 + """Run 'git rev-list' with the given list of revision arguments.
402 +
403 + See git_rev_list_ish() for parameter and return value
404 + documentation.
405 + """
406 + return git_rev_list_ish('rev-list', spec, **kw)
407 +
408 +
409 +def git_log(spec, **kw):
410 + """Run 'git log' with the given list of revision arguments.
411 +
412 + See git_rev_list_ish() for parameter and return value
413 + documentation.
414 + """
415 + return git_rev_list_ish('log', spec, **kw)
416 +
417 +
418 def header_encode(text, header_name=None):
419 """Encode and line-wrap the value of an email header field."""
420
@@ -388,9 +467,9 @@ class Config(object):
467 def get(self, name, default=None):
468 try:
469 values = self._split(read_git_output(
391 - ['config', '--get', '--null', '%s.%s' % (self.section, name)],
392 - env=self.env, keepends=True,
393 - ))
470 + ['config', '--get', '--null', '%s.%s' % (self.section, name)],
471 + env=self.env, keepends=True,
472 + ))
473 assert len(values) == 1
474 return values[0]
475 except CommandError:
@@ -449,9 +528,14 @@ class Config(object):
528 env=self.env,
529 )
530
452 - def has_key(self, name):
531 + def __contains__(self, name):
532 return self.get_all(name, default=None) is not None
533
534 + # We don't use this method anymore internally, but keep it here in
535 + # case somebody is calling it from their own code:
536 + def has_key(self, name):
537 + return name in self
538 +
539 def unset_all(self, name):
540 try:
541 read_git_output(
@@ -579,7 +663,7 @@ class Change(object):
663 self._values = None
664
665 def _compute_values(self):
582 - """Return a dictionary {keyword : expansion} for this Change.
666 + """Return a dictionary {keyword: expansion} for this Change.
667
668 Derived classes overload this method to add more entries to
669 the return value. This method is used internally by
@@ -589,7 +673,7 @@ class Change(object):
673 return self.environment.get_values()
674
675 def get_values(self, **extra_values):
592 - """Return a dictionary {keyword : expansion} for this Change.
676 + """Return a dictionary {keyword: expansion} for this Change.
677
678 Return a dictionary mapping keywords to the values that they
679 should be expanded to for this Change (used when interpolating
@@ -636,7 +720,7 @@ class Change(object):
720 value = value % values
721 except KeyError, e:
722 if DEBUG:
639 - sys.stderr.write(
723 + self.environment.log_warning(
724 'Warning: unknown variable %r in the following line; line skipped:\n'
725 ' %s\n'
726 % (e.args[0], line,)
@@ -711,6 +795,8 @@ class Change(object):
795 class Revision(Change):
796 """A Change consisting of a single git commit."""
797
798 + CC_RE = re.compile(r'^\s*C[Cc]:\s*(?P<to>[^#]+@[^\s#]*)\s*(#.*)?$')
799 +
800 def __init__(self, reference_change, rev, num, tot):
801 Change.__init__(self, reference_change.environment)
802 self.reference_change = reference_change
@@ -722,6 +808,24 @@ class Revision(Change):
808 self.author = read_git_output(['log', '--no-walk', '--format=%aN <%aE>', self.rev.sha1])
809 self.recipients = self.environment.get_revision_recipients(self)
810
811 + self.cc_recipients = ''
812 + if self.environment.get_scancommitforcc():
813 + self.cc_recipients = ', '.join(to.strip() for to in self._cc_recipients())
814 + if self.cc_recipients:
815 + self.environment.log_msg(
816 + 'Add %s to CC for %s\n' % (self.cc_recipients, self.rev.sha1))
817 +
818 + def _cc_recipients(self):
819 + cc_recipients = []
820 + message = read_git_output(['log', '--no-walk', '--format=%b', self.rev.sha1])
821 + lines = message.strip().split('\n')
822 + for line in lines:
823 + m = re.match(self.CC_RE, line)
824 + if m:
825 + cc_recipients.append(m.group('to'))
826 +
827 + return cc_recipients
828 +
829 def _compute_values(self):
830 values = Change._compute_values(self)
831
@@ -739,6 +843,8 @@ class Revision(Change):
843 values['num'] = self.num
844 values['tot'] = self.tot
845 values['recipients'] = self.recipients
846 + if self.cc_recipients:
847 + values['cc_recipients'] = self.cc_recipients
848 values['oneline'] = oneline
849 values['author'] = self.author
850
@@ -750,8 +856,8 @@ class Revision(Change):
856
857 def generate_email_header(self, **extra_values):
858 for line in self.expand_header_lines(
753 - REVISION_HEADER_TEMPLATE, **extra_values
754 - ):
859 + REVISION_HEADER_TEMPLATE, **extra_values
860 + ):
861 yield line
862
863 def generate_email_intro(self):
@@ -822,26 +928,26 @@ class ReferenceChange(Change):
928 klass = BranchChange
929 elif area == 'remotes':
930 # Tracking branch:
825 - sys.stderr.write(
931 + environment.log_warning(
932 '*** Push-update of tracking branch %r\n'
933 '*** - incomplete email generated.\n'
828 - % (refname,)
934 + % (refname,)
935 )
936 klass = OtherReferenceChange
937 else:
938 # Some other reference namespace:
833 - sys.stderr.write(
939 + environment.log_warning(
940 '*** Push-update of strange reference %r\n'
941 '*** - incomplete email generated.\n'
836 - % (refname,)
942 + % (refname,)
943 )
944 klass = OtherReferenceChange
945 else:
946 # Anything else (is there anything else?)
841 - sys.stderr.write(
947 + environment.log_warning(
948 '*** Unknown type of update to %r (%s)\n'
949 '*** - incomplete email generated.\n'
844 - % (refname, rev.type,)
950 + % (refname, rev.type,)
951 )
952 klass = OtherReferenceChange
953
@@ -854,9 +960,9 @@ class ReferenceChange(Change):
960 def __init__(self, environment, refname, short_refname, old, new, rev):
961 Change.__init__(self, environment)
962 self.change_type = {
857 - (False, True) : 'create',
858 - (True, True) : 'update',
859 - (True, False) : 'delete',
963 + (False, True): 'create',
964 + (True, True): 'update',
965 + (True, False): 'delete',
966 }[bool(old), bool(new)]
967 self.refname = refname
968 self.short_refname = short_refname
@@ -865,10 +971,16 @@ class ReferenceChange(Change):
971 self.rev = rev
972 self.msgid = make_msgid()
973 self.diffopts = environment.diffopts
974 + self.graphopts = environment.graphopts
975 self.logopts = environment.logopts
976 self.commitlogopts = environment.commitlogopts
977 + self.showgraph = environment.refchange_showgraph
978 self.showlog = environment.refchange_showlog
979
980 + self.header_template = REFCHANGE_HEADER_TEMPLATE
981 + self.intro_template = REFCHANGE_INTRO_TEMPLATE
982 + self.footer_template = FOOTER_TEMPLATE
983 +
984 def _compute_values(self):
985 values = Change._compute_values(self)
986
@@ -894,11 +1006,39 @@ class ReferenceChange(Change):
1006
1007 return values
1008
1009 + def send_single_combined_email(self, known_added_sha1s):
1010 + """Determine if a combined refchange/revision email should be sent
1011 +
1012 + If there is only a single new (non-merge) commit added by a
1013 + change, it is useful to combine the ReferenceChange and
1014 + Revision emails into one. In such a case, return the single
1015 + revision; otherwise, return None.
1016 +
1017 + This method is overridden in BranchChange."""
1018 +
1019 + return None
1020 +
1021 + def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):
1022 + """Generate an email describing this change AND specified revision.
1023 +
1024 + Iterate over the lines (including the header lines) of an
1025 + email describing this change. If body_filter is not None,
1026 + then use it to filter the lines that are intended for the
1027 + email body.
1028 +
1029 + The extra_header_values field is received as a dict and not as
1030 + **kwargs, to allow passing other keyword arguments in the
1031 + future (e.g. passing extra values to generate_email_intro()
1032 +
1033 + This method is overridden in BranchChange."""
1034 +
1035 + raise NotImplementedError
1036 +
1037 def get_subject(self):
1038 template = {
899 - 'create' : REF_CREATED_SUBJECT_TEMPLATE,
900 - 'update' : REF_UPDATED_SUBJECT_TEMPLATE,
901 - 'delete' : REF_DELETED_SUBJECT_TEMPLATE,
1039 + 'create': REF_CREATED_SUBJECT_TEMPLATE,
1040 + 'update': REF_UPDATED_SUBJECT_TEMPLATE,
1041 + 'delete': REF_DELETED_SUBJECT_TEMPLATE,
1042 }[self.change_type]
1043 return self.expand(template)
1044
@@ -907,12 +1047,12 @@ class ReferenceChange(Change):
1047 extra_values['subject'] = self.get_subject()
1048
1049 for line in self.expand_header_lines(
910 - REFCHANGE_HEADER_TEMPLATE, **extra_values
911 - ):
1050 + self.header_template, **extra_values
1051 + ):
1052 yield line
1053
1054 def generate_email_intro(self):
915 - for line in self.expand_lines(REFCHANGE_INTRO_TEMPLATE):
1055 + for line in self.expand_lines(self.intro_template):
1056 yield line
1057
1058 def generate_email_body(self, push):
@@ -922,9 +1062,9 @@ class ReferenceChange(Change):
1062 generate_update_summary() / generate_delete_summary()."""
1063
1064 change_summary = {
925 - 'create' : self.generate_create_summary,
926 - 'delete' : self.generate_delete_summary,
927 - 'update' : self.generate_update_summary,
1065 + 'create': self.generate_create_summary,
1066 + 'delete': self.generate_delete_summary,
1067 + 'update': self.generate_update_summary,
1068 }[self.change_type](push)
1069 for line in change_summary:
1070 yield line
@@ -933,7 +1073,23 @@ class ReferenceChange(Change):
1073 yield line
1074
1075 def generate_email_footer(self):
936 - return self.expand_lines(FOOTER_TEMPLATE)
1076 + return self.expand_lines(self.footer_template)
1077 +
1078 + def generate_revision_change_graph(self, push):
1079 + if self.showgraph:
1080 + args = ['--graph'] + self.graphopts
1081 + for newold in ('new', 'old'):
1082 + has_newold = False
1083 + spec = push.get_commits_spec(newold, self)
1084 + for line in git_log(spec, args=args, keepends=True):
1085 + if not has_newold:
1086 + has_newold = True
1087 + yield '\n'
1088 + yield 'Graph of %s commits:\n\n' % (
1089 + {'new': 'new', 'old': 'discarded'}[newold],)
1090 + yield ' ' + line
1091 + if has_newold:
1092 + yield '\n'
1093
1094 def generate_revision_change_log(self, new_commits_list):
1095 if self.showlog:
@@ -945,9 +1101,17 @@ class ReferenceChange(Change):
1101 + new_commits_list
1102 + ['--'],
1103 keepends=True,
948 - ):
1104 + ):
1105 yield line
1106
1107 + def generate_new_revision_summary(self, tot, new_commits_list, push):
1108 + for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot):
1109 + yield line
1110 + for line in self.generate_revision_change_graph(push):
1111 + yield line
1112 + for line in self.generate_revision_change_log(new_commits_list):
1113 + yield line
1114 +
1115 def generate_revision_change_summary(self, push):
1116 """Generate a summary of the revisions added/removed by this change."""
1117
@@ -960,7 +1124,7 @@ class ReferenceChange(Change):
1124 sha1s.reverse()
1125 tot = len(sha1s)
1126 new_revisions = [
963 - Revision(self, GitObject(sha1), num=i+1, tot=tot)
1127 + Revision(self, GitObject(sha1), num=i + 1, tot=tot)
1128 for (i, sha1) in enumerate(sha1s)
1129 ]
1130
@@ -973,9 +1137,8 @@ class ReferenceChange(Change):
1137 BRIEF_SUMMARY_TEMPLATE, action='new', text=subject,
1138 )
1139 yield '\n'
976 - for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot):
977 - yield line
978 - for line in self.generate_revision_change_log([r.rev.sha1 for r in new_revisions]):
1140 + for line in self.generate_new_revision_summary(
1141 + tot, [r.rev.sha1 for r in new_revisions], push):
1142 yield line
1143 else:
1144 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
@@ -993,16 +1156,16 @@ class ReferenceChange(Change):
1156 # revisions in the summary even though we will not send
1157 # new notification emails for them.
1158 adds = list(generate_summaries(
996 - '--topo-order', '--reverse', '%s..%s'
997 - % (self.old.commit_sha1, self.new.commit_sha1,)
998 - ))
1159 + '--topo-order', '--reverse', '%s..%s'
1160 + % (self.old.commit_sha1, self.new.commit_sha1,)
1161 + ))
1162
1163 # List of the revisions that were removed from the branch
1164 # by this update. This will be empty except for
1165 # non-fast-forward updates.
1166 discards = list(generate_summaries(
1004 - '%s..%s' % (self.new.commit_sha1, self.old.commit_sha1,)
1005 - ))
1167 + '%s..%s' % (self.new.commit_sha1, self.old.commit_sha1,)
1168 + ))
1169
1170 if adds:
1171 new_commits_list = push.get_new_commits(self)
@@ -1071,13 +1234,14 @@ class ReferenceChange(Change):
1234 yield '\n'
1235
1236 if new_commits:
1074 - for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=len(new_commits)):
1075 - yield line
1076 - for line in self.generate_revision_change_log(new_commits_list):
1237 + for line in self.generate_new_revision_summary(
1238 + len(new_commits), new_commits_list, push):
1239 yield line
1240 else:
1241 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
1242 yield line
1243 + for line in self.generate_revision_change_graph(push):
1244 + yield line
1245
1246 # The diffstat is shown from the old revision to the new
1247 # revision. This is to show the truth of what happened in
@@ -1089,11 +1253,11 @@ class ReferenceChange(Change):
1253 yield '\n'
1254 yield 'Summary of changes:\n'
1255 for line in read_git_lines(
1092 - ['diff-tree']
1093 - + self.diffopts
1094 - + ['%s..%s' % (self.old.commit_sha1, self.new.commit_sha1,)],
1095 - keepends=True,
1096 - ):
1256 + ['diff-tree']
1257 + + self.diffopts
1258 + + ['%s..%s' % (self.old.commit_sha1, self.new.commit_sha1,)],
1259 + keepends=True,
1260 + ):
1261 yield line
1262
1263 elif self.old.commit_sha1 and not self.new.commit_sha1:
@@ -1103,7 +1267,7 @@ class ReferenceChange(Change):
1267 sha1s = list(push.get_discarded_commits(self))
1268 tot = len(sha1s)
1269 discarded_revisions = [
1106 - Revision(self, GitObject(sha1), num=i+1, tot=tot)
1270 + Revision(self, GitObject(sha1), num=i + 1, tot=tot)
1271 for (i, sha1) in enumerate(sha1s)
1272 ]
1273
@@ -1116,6 +1280,8 @@ class ReferenceChange(Change):
1280 yield r.expand(
1281 BRIEF_SUMMARY_TEMPLATE, action='discards', text=subject,
1282 )
1283 + for line in self.generate_revision_change_graph(push):
1284 + yield line
1285 else:
1286 for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE):
1287 yield line
@@ -1161,6 +1327,150 @@ class BranchChange(ReferenceChange):
1327 old=old, new=new, rev=rev,
1328 )
1329 self.recipients = environment.get_refchange_recipients(self)
1330 + self._single_revision = None
1331 +
1332 + def send_single_combined_email(self, known_added_sha1s):
1333 + if not self.environment.combine_when_single_commit:
1334 + return None
1335 +
1336 + # In the sadly-all-too-frequent usecase of people pushing only
1337 + # one of their commits at a time to a repository, users feel
1338 + # the reference change summary emails are noise rather than
1339 + # important signal. This is because, in this particular
1340 + # usecase, there is a reference change summary email for each
1341 + # new commit, and all these summaries do is point out that
1342 + # there is one new commit (which can readily be inferred by
1343 + # the existence of the individual revision email that is also
1344 + # sent). In such cases, our users prefer there to be a combined
1345 + # reference change summary/new revision email.
1346 + #
1347 + # So, if the change is an update and it doesn't discard any
1348 + # commits, and it adds exactly one non-merge commit (gerrit
1349 + # forces a workflow where every commit is individually merged
1350 + # and the git-multimail hook fired off for just this one
1351 + # change), then we send a combined refchange/revision email.
1352 + try:
1353 + # If this change is a reference update that doesn't discard
1354 + # any commits...
1355 + if self.change_type != 'update':
1356 + return None
1357 +
1358 + if read_git_lines(
1359 + ['merge-base', self.old.sha1, self.new.sha1]
1360 + ) != [self.old.sha1]:
1361 + return None
1362 +
1363 + # Check if this update introduced exactly one non-merge
1364 + # commit:
1365 +
1366 + def split_line(line):
1367 + """Split line into (sha1, [parent,...])."""
1368 +
1369 + words = line.split()
1370 + return (words[0], words[1:])
1371 +
1372 + # Get the new commits introduced by the push as a list of
1373 + # (sha1, [parent,...])
1374 + new_commits = [
1375 + split_line(line)
1376 + for line in read_git_lines(
1377 + [
1378 + 'log', '-3', '--format=%H %P',
1379 + '%s..%s' % (self.old.sha1, self.new.sha1),
1380 + ]
1381 + )
1382 + ]
1383 +
1384 + if not new_commits:
1385 + return None
1386 +
1387 + # If the newest commit is a merge, save it for a later check
1388 + # but otherwise ignore it
1389 + merge = None
1390 + tot = len(new_commits)
1391 + if len(new_commits[0][1]) > 1:
1392 + merge = new_commits[0][0]
1393 + del new_commits[0]
1394 +
1395 + # Our primary check: we can't combine if more than one commit
1396 + # is introduced. We also currently only combine if the new
1397 + # commit is a non-merge commit, though it may make sense to
1398 + # combine if it is a merge as well.
1399 + 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
1403 + ):
1404 + return None
1405 +
1406 + # We do not want to combine revision and refchange emails if
1407 + # those go to separate locations.
1408 + rev = Revision(self, GitObject(new_commits[0][0]), 1, tot)
1409 + if rev.recipients != self.recipients:
1410 + return None
1411 +
1412 + # We ignored the newest commit if it was just a merge of the one
1413 + # commit being introduced. But we don't want to ignore that
1414 + # merge commit it it involved conflict resolutions. Check that.
1415 + if merge and merge != read_git_output(['diff-tree', '--cc', merge]):
1416 + return None
1417 +
1418 + # We can combine the refchange and one new revision emails
1419 + # into one. Return the Revision that a combined email should
1420 + # be sent about.
1421 + return rev
1422 + except CommandError:
1423 + # Cannot determine number of commits in old..new or new..old;
1424 + # don't combine reference/revision emails:
1425 + return None
1426 +
1427 + def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):
1428 + values = revision.get_values()
1429 + if extra_header_values:
1430 + values.update(extra_header_values)
1431 + if 'subject' not in extra_header_values:
1432 + values['subject'] = self.expand(COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE, **values)
1433 +
1434 + self._single_revision = revision
1435 + self.header_template = COMBINED_HEADER_TEMPLATE
1436 + self.intro_template = COMBINED_INTRO_TEMPLATE
1437 + self.footer_template = COMBINED_FOOTER_TEMPLATE
1438 + for line in self.generate_email(push, body_filter, values):
1439 + yield line
1440 +
1441 + def generate_email_body(self, push):
1442 + '''Call the appropriate body generation routine.
1443 +
1444 + If this is a combined refchange/revision email, the special logic
1445 + for handling this combined email comes from this function. For
1446 + other cases, we just use the normal handling.'''
1447 +
1448 + # If self._single_revision isn't set; don't override
1449 + if not self._single_revision:
1450 + for line in super(BranchChange, self).generate_email_body(push):
1451 + yield line
1452 + return
1453 +
1454 + # This is a combined refchange/revision email; we first provide
1455 + # some info from the refchange portion, and then call the revision
1456 + # generate_email_body function to handle the revision portion.
1457 + adds = list(generate_summaries(
1458 + '--topo-order', '--reverse', '%s..%s'
1459 + % (self.old.commit_sha1, self.new.commit_sha1,)
1460 + ))
1461 +
1462 + yield self.expand("The following commit(s) were added to %(refname)s by this push:\n")
1463 + for (sha1, subject) in adds:
1464 + yield self.expand(
1465 + BRIEF_SUMMARY_TEMPLATE, action='new',
1466 + rev_short=sha1, text=subject,
1467 + )
1468 +
1469 + yield self._single_revision.rev.short + " is described below\n"
1470 + yield '\n'
1471 +
1472 + for line in self._single_revision.generate_email_body(push):
1473 + yield line
1474
1475
1476 class AnnotatedTagChange(ReferenceChange):
@@ -1390,13 +1700,17 @@ class SendMailer(Mailer):
1700 sys.exit(1)
1701 try:
1702 p.stdin.writelines(lines)
1393 - except:
1703 + except Exception, e:
1704 sys.stderr.write(
1705 '*** Error while generating commit email\n'
1706 '*** - mail sending aborted.\n'
1707 )
1398 - p.terminate()
1399 - raise
1708 + try:
1709 + # subprocess.terminate() is not available in Python 2.4
1710 + p.terminate()
1711 + except AttributeError:
1712 + pass
1713 + raise e
1714 else:
1715 p.stdin.close()
1716 retcode = p.wait()
@@ -1407,34 +1721,72 @@ class SendMailer(Mailer):
1721 class SMTPMailer(Mailer):
1722 """Send emails using Python's smtplib."""
1723
1410 - def __init__(self, envelopesender, smtpserver):
1724 + def __init__(self, envelopesender, smtpserver,
1725 + smtpservertimeout=10.0, smtpserverdebuglevel=0,
1726 + smtpencryption='none',
1727 + smtpuser='', smtppass='',
1728 + ):
1729 if not envelopesender:
1730 sys.stderr.write(
1731 'fatal: git_multimail: cannot use SMTPMailer without a sender address.\n'
1732 'please set either multimailhook.envelopeSender or user.email\n'
1733 )
1734 sys.exit(1)
1735 + if smtpencryption == 'ssl' and not (smtpuser and smtppass):
1736 + raise ConfigurationException(
1737 + 'Cannot use SMTPMailer with security option ssl '
1738 + 'without options username and password.'
1739 + )
1740 self.envelopesender = envelopesender
1741 self.smtpserver = smtpserver
1742 + self.smtpservertimeout = smtpservertimeout
1743 + self.smtpserverdebuglevel = smtpserverdebuglevel
1744 + self.security = smtpencryption
1745 + self.username = smtpuser
1746 + self.password = smtppass
1747 try:
1420 - self.smtp = smtplib.SMTP(self.smtpserver)
1748 + if self.security == 'none':
1749 + self.smtp = smtplib.SMTP(self.smtpserver, timeout=self.smtpservertimeout)
1750 + elif self.security == 'ssl':
1751 + self.smtp = smtplib.SMTP_SSL(self.smtpserver, timeout=self.smtpservertimeout)
1752 + elif self.security == 'tls':
1753 + if ':' not in self.smtpserver:
1754 + self.smtpserver += ':587' # default port for TLS
1755 + self.smtp = smtplib.SMTP(self.smtpserver, timeout=self.smtpservertimeout)
1756 + self.smtp.ehlo()
1757 + self.smtp.starttls()
1758 + self.smtp.ehlo()
1759 + else:
1760 + sys.stdout.write('*** Error: Control reached an invalid option. ***')
1761 + sys.exit(1)
1762 + if self.smtpserverdebuglevel > 0:
1763 + sys.stdout.write(
1764 + "*** Setting debug on for SMTP server connection (%s) ***\n"
1765 + % self.smtpserverdebuglevel)
1766 + self.smtp.set_debuglevel(self.smtpserverdebuglevel)
1767 except Exception, e:
1422 - sys.stderr.write('*** Error establishing SMTP connection to %s***\n' % self.smtpserver)
1768 + sys.stderr.write(
1769 + '*** Error establishing SMTP connection to %s ***\n'
1770 + % self.smtpserver)
1771 sys.stderr.write('*** %s\n' % str(e))
1772 sys.exit(1)
1773
1774 def __del__(self):
1427 - self.smtp.quit()
1775 + if hasattr(self, 'smtp'):
1776 + self.smtp.quit()
1777
1778 def send(self, lines, to_addrs):
1779 try:
1780 + if self.username or self.password:
1781 + sys.stderr.write("*** Authenticating as %s ***\n" % self.username)
1782 + self.smtp.login(self.username, self.password)
1783 msg = ''.join(lines)
1784 # turn comma-separated list into Python list if needed.
1785 if isinstance(to_addrs, basestring):
1786 to_addrs = [email for (name, email) in getaddresses([to_addrs])]
1787 self.smtp.sendmail(self.envelopesender, to_addrs, msg)
1788 except Exception, e:
1437 - sys.stderr.write('*** Error sending email***\n')
1789 + sys.stderr.write('*** Error sending email ***\n')
1790 sys.stderr.write('*** %s\n' % str(e))
1791 self.smtp.quit()
1792 sys.exit(1)
@@ -1549,6 +1901,10 @@ class Environment(object):
1901
1902 True iff announce emails should include a shortlog.
1903
1904 + refchange_showgraph (bool)
1905 +
1906 + True iff refchanges emails should include a detailed graph.
1907 +
1908 refchange_showlog (bool)
1909
1910 True iff refchanges emails should include a detailed log.
@@ -1559,6 +1915,12 @@ class Environment(object):
1915 summary email. The value should be a list of strings
1916 representing words to be passed to the command.
1917
1918 + graphopts (list of strings)
1919 +
1920 + Analogous to diffopts, but contains options passed to
1921 + 'git log --graph' when generating the detailed graph for
1922 + a set of commits (see refchange_showgraph)
1923 +
1924 logopts (list of strings)
1925
1926 Analogous to diffopts, but contains options passed to
@@ -1571,6 +1933,17 @@ class Environment(object):
1933 commit mail. The value should be a list of strings
1934 representing words to be passed to the command.
1935
1936 + quiet (bool)
1937 + On success do not write to stderr
1938 +
1939 + stdout (bool)
1940 + Write email to stdout rather than emailing. Useful for debugging
1941 +
1942 + combine_when_single_commit (bool)
1943 +
1944 + True if a combined email should be produced when a single
1945 + new commit is pushed to a branch, False otherwise.
1946 +
1947 """
1948
1949 REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$')
@@ -1580,9 +1953,14 @@ class Environment(object):
1953 self.announce_show_shortlog = False
1954 self.maxcommitemails = 500
1955 self.diffopts = ['--stat', '--summary', '--find-copies-harder']
1956 + self.graphopts = ['--oneline', '--decorate']
1957 self.logopts = []
1958 + self.refchange_showgraph = False
1959 self.refchange_showlog = False
1960 self.commitlogopts = ['-C', '--stat', '-p', '--cc']
1961 + self.quiet = False
1962 + self.stdout = False
1963 + self.combine_when_single_commit = True
1964
1965 self.COMPUTED_KEYS = [
1966 'administrator',
@@ -1614,6 +1992,14 @@ class Environment(object):
1992 def get_pusher_email(self):
1993 return None
1994
1995 + def get_fromaddr(self):
1996 + config = Config('user')
1997 + fromname = config.get('name', default='')
1998 + fromemail = config.get('email', default='')
1999 + if fromemail:
2000 + return formataddr([fromname, fromemail])
2001 + return self.get_sender()
2002 +
2003 def get_administrator(self):
2004 return 'the administrator of this repository'
2005
@@ -1631,7 +2017,7 @@ class Environment(object):
2017 return CHARSET
2018
2019 def get_values(self):
1634 - """Return a dictionary {keyword : expansion} for this Environment.
2020 + """Return a dictionary {keyword: expansion} for this Environment.
2021
2022 This method is called by Change._compute_values(). The keys
2023 in the returned dictionary are available to be used in any of
@@ -1699,6 +2085,24 @@ class Environment(object):
2085
2086 return lines
2087
2088 + def log_msg(self, msg):
2089 + """Write the string msg on a log file or on stderr.
2090 +
2091 + Sends the text to stderr by default, override to change the behavior."""
2092 + sys.stderr.write(msg)
2093 +
2094 + def log_warning(self, msg):
2095 + """Write the string msg on a log file or on stderr.
2096 +
2097 + Sends the text to stderr by default, override to change the behavior."""
2098 + sys.stderr.write(msg)
2099 +
2100 + def log_error(self, msg):
2101 + """Write the string msg on a log file or on stderr.
2102 +
2103 + Sends the text to stderr by default, override to change the behavior."""
2104 + sys.stderr.write(msg)
2105 +
2106
2107 class ConfigEnvironmentMixin(Environment):
2108 """A mixin that sets self.config to its constructor's config argument.
@@ -1723,20 +2127,23 @@ class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):
2127 config=config, **kw
2128 )
2129
1726 - self.announce_show_shortlog = config.get_bool(
1727 - 'announceshortlog', default=self.announce_show_shortlog
1728 - )
1729 -
1730 - self.refchange_showlog = config.get_bool(
1731 - 'refchangeshowlog', default=self.refchange_showlog
1732 - )
2130 + for var, cfg in (
2131 + ('announce_show_shortlog', 'announceshortlog'),
2132 + ('refchange_showgraph', 'refchangeShowGraph'),
2133 + ('refchange_showlog', 'refchangeshowlog'),
2134 + ('quiet', 'quiet'),
2135 + ('stdout', 'stdout'),
2136 + ):
2137 + val = config.get_bool(cfg)
2138 + if val is not None:
2139 + setattr(self, var, val)
2140
2141 maxcommitemails = config.get('maxcommitemails')
2142 if maxcommitemails is not None:
2143 try:
2144 self.maxcommitemails = int(maxcommitemails)
2145 except ValueError:
1739 - sys.stderr.write(
2146 + self.log_warning(
2147 '*** Malformed value for multimailhook.maxCommitEmails: %s\n' % maxcommitemails
2148 + '*** Expected a number. Ignoring.\n'
2149 )
@@ -1745,6 +2152,10 @@ class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):
2152 if diffopts is not None:
2153 self.diffopts = shlex.split(diffopts)
2154
2155 + graphopts = config.get('graphOpts')
2156 + if graphopts is not None:
2157 + self.graphopts = shlex.split(graphopts)
2158 +
2159 logopts = config.get('logopts')
2160 if logopts is not None:
2161 self.logopts = shlex.split(logopts)
@@ -1756,14 +2167,18 @@ class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):
2167 reply_to = config.get('replyTo')
2168 self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to)
2169 if (
1759 - self.__reply_to_refchange is not None
1760 - and self.__reply_to_refchange.lower() == 'author'
1761 - ):
2170 + self.__reply_to_refchange is not None
2171 + and self.__reply_to_refchange.lower() == 'author'
2172 + ):
2173 raise ConfigurationException(
2174 '"author" is not an allowed setting for replyToRefchange'
2175 )
2176 self.__reply_to_commit = config.get('replyToCommit', default=reply_to)
2177
2178 + combine = config.get_bool('combineWhenSingleCommit')
2179 + if combine is not None:
2180 + self.combine_when_single_commit = combine
2181 +
2182 def get_administrator(self):
2183 return (
2184 self.config.get('administrator')
@@ -1779,8 +2194,12 @@ class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):
2194
2195 def get_emailprefix(self):
2196 emailprefix = self.config.get('emailprefix')
1782 - if emailprefix and emailprefix.strip():
1783 - return emailprefix.strip() + ' '
2197 + if emailprefix is not None:
2198 + emailprefix = emailprefix.strip()
2199 + if emailprefix:
2200 + return emailprefix + ' '
2201 + else:
2202 + return ''
2203 else:
2204 return '[%s] ' % (self.get_repo_shortname(),)
2205
@@ -1791,14 +2210,7 @@ class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):
2210 fromaddr = self.config.get('from')
2211 if fromaddr:
2212 return fromaddr
1794 - else:
1795 - config = Config('user')
1796 - fromname = config.get('name', default='')
1797 - fromemail = config.get('email', default='')
1798 - if fromemail:
1799 - return formataddr([fromname, fromemail])
1800 - else:
1801 - return self.get_sender()
2213 + return super(ConfigOptionsEnvironmentMixin, self).get_fromaddr()
2214
2215 def get_reply_to_refchange(self, refchange):
2216 if self.__reply_to_refchange is None:
@@ -1814,7 +2226,7 @@ class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):
2226 if self.__reply_to_commit is None:
2227 return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_commit(revision)
2228 elif self.__reply_to_commit.lower() == 'author':
1817 - return revision.get_author()
2229 + return revision.author
2230 elif self.__reply_to_commit.lower() == 'pusher':
2231 return self.get_pusher_email()
2232 elif self.__reply_to_commit.lower() == 'none':
@@ -1822,6 +2234,9 @@ class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):
2234 else:
2235 return self.__reply_to_commit
2236
2237 + def get_scancommitforcc(self):
2238 + return self.config.get('scancommitforcc')
2239 +
2240
2241 class FilterLinesEnvironmentMixin(Environment):
2242 """Handle encoding and maximum line length of body lines.
@@ -1862,9 +2277,9 @@ class FilterLinesEnvironmentMixin(Environment):
2277
2278
2279 class ConfigFilterLinesEnvironmentMixin(
1865 - ConfigEnvironmentMixin,
1866 - FilterLinesEnvironmentMixin,
1867 - ):
2280 + ConfigEnvironmentMixin,
2281 + FilterLinesEnvironmentMixin,
2282 + ):
2283 """Handle encoding and maximum line length based on config."""
2284
2285 def __init__(self, config, **kw):
@@ -1896,9 +2311,9 @@ class MaxlinesEnvironmentMixin(Environment):
2311
2312
2313 class ConfigMaxlinesEnvironmentMixin(
1899 - ConfigEnvironmentMixin,
1900 - MaxlinesEnvironmentMixin,
1901 - ):
2314 + ConfigEnvironmentMixin,
2315 + MaxlinesEnvironmentMixin,
2316 + ):
2317 """Limit the email body to the number of lines specified in config."""
2318
2319 def __init__(self, config, **kw):
@@ -1927,9 +2342,9 @@ class FQDNEnvironmentMixin(Environment):
2342
2343
2344 class ConfigFQDNEnvironmentMixin(
1930 - ConfigEnvironmentMixin,
1931 - FQDNEnvironmentMixin,
1932 - ):
2345 + ConfigEnvironmentMixin,
2346 + FQDNEnvironmentMixin,
2347 + ):
2348 """Read the FQDN from the config."""
2349
2350 def __init__(self, config, **kw):
@@ -1970,10 +2385,10 @@ class StaticRecipientsEnvironmentMixin(Environment):
2385 """Set recipients statically based on constructor parameters."""
2386
2387 def __init__(
1973 - self,
1974 - refchange_recipients, announce_recipients, revision_recipients,
1975 - **kw
1976 - ):
2388 + self,
2389 + refchange_recipients, announce_recipients, revision_recipients, scancommitforcc,
2390 + **kw
2391 + ):
2392 super(StaticRecipientsEnvironmentMixin, self).__init__(**kw)
2393
2394 # The recipients for various types of notification emails, as
@@ -1985,7 +2400,8 @@ class StaticRecipientsEnvironmentMixin(Environment):
2400 # compute them once and for all:
2401 if not (refchange_recipients
2402 or announce_recipients
1988 - or revision_recipients):
2403 + or revision_recipients
2404 + or scancommitforcc):
2405 raise ConfigurationException('No email recipients configured!')
2406 self.__refchange_recipients = refchange_recipients
2407 self.__announce_recipients = announce_recipients
@@ -2002,9 +2418,9 @@ class StaticRecipientsEnvironmentMixin(Environment):
2418
2419
2420 class ConfigRecipientsEnvironmentMixin(
2005 - ConfigEnvironmentMixin,
2006 - StaticRecipientsEnvironmentMixin
2007 - ):
2421 + ConfigEnvironmentMixin,
2422 + StaticRecipientsEnvironmentMixin
2423 + ):
2424 """Determine recipients statically based on config."""
2425
2426 def __init__(self, config, **kw):
@@ -2019,6 +2435,7 @@ class ConfigRecipientsEnvironmentMixin(
2435 revision_recipients=self._get_recipients(
2436 config, 'commitlist', 'mailinglist',
2437 ),
2438 + scancommitforcc=config.get('scancommitforcc'),
2439 **kw
2440 )
2441
@@ -2067,20 +2484,20 @@ class ProjectdescEnvironmentMixin(Environment):
2484
2485 class GenericEnvironmentMixin(Environment):
2486 def get_pusher(self):
2070 - return self.osenv.get('USER', 'unknown user')
2487 + return self.osenv.get('USER', self.osenv.get('USERNAME', 'unknown user'))
2488
2489
2490 class GenericEnvironment(
2074 - ProjectdescEnvironmentMixin,
2075 - ConfigMaxlinesEnvironmentMixin,
2076 - ComputeFQDNEnvironmentMixin,
2077 - ConfigFilterLinesEnvironmentMixin,
2078 - ConfigRecipientsEnvironmentMixin,
2079 - PusherDomainEnvironmentMixin,
2080 - ConfigOptionsEnvironmentMixin,
2081 - GenericEnvironmentMixin,
2082 - Environment,
2083 - ):
2491 + ProjectdescEnvironmentMixin,
2492 + ConfigMaxlinesEnvironmentMixin,
2493 + ComputeFQDNEnvironmentMixin,
2494 + ConfigFilterLinesEnvironmentMixin,
2495 + ConfigRecipientsEnvironmentMixin,
2496 + PusherDomainEnvironmentMixin,
2497 + ConfigOptionsEnvironmentMixin,
2498 + GenericEnvironmentMixin,
2499 + Environment,
2500 + ):
2501 pass
2502
2503
@@ -2097,6 +2514,45 @@ class GitoliteEnvironmentMixin(Environment):
2514 def get_pusher(self):
2515 return self.osenv.get('GL_USER', 'unknown user')
2516
2517 + def get_fromaddr(self):
2518 + GL_USER = self.osenv.get('GL_USER')
2519 + if GL_USER is not None:
2520 + # Find the path to gitolite.conf. Note that gitolite v3
2521 + # did away with the GL_ADMINDIR and GL_CONF environment
2522 + # variables (they are now hard-coded).
2523 + GL_ADMINDIR = self.osenv.get(
2524 + 'GL_ADMINDIR',
2525 + os.path.expanduser(os.path.join('~', '.gitolite')))
2526 + GL_CONF = self.osenv.get(
2527 + 'GL_CONF',
2528 + os.path.join(GL_ADMINDIR, 'conf', 'gitolite.conf'))
2529 + if os.path.isfile(GL_CONF):
2530 + f = open(GL_CONF, 'rU')
2531 + try:
2532 + in_user_emails_section = False
2533 + re_template = r'^\s*#\s*{}\s*$'
2534 + re_begin, re_user, re_end = (
2535 + re.compile(re_template.format(x))
2536 + for x in (
2537 + r'BEGIN\s+USER\s+EMAILS',
2538 + re.escape(GL_USER) + r'\s+(.*)',
2539 + r'END\s+USER\s+EMAILS',
2540 + ))
2541 + for l in f:
2542 + l = l.rstrip('\n')
2543 + if not in_user_emails_section:
2544 + if re_begin.match(l):
2545 + in_user_emails_section = True
2546 + continue
2547 + if re_end.match(l):
2548 + break
2549 + m = re_user.match(l)
2550 + if m:
2551 + return m.group(1)
2552 + finally:
2553 + f.close()
2554 + return super(GitoliteEnvironmentMixin, self).get_fromaddr()
2555 +
2556
2557 class IncrementalDateTime(object):
2558 """Simple wrapper to give incremental date/times.
@@ -2116,16 +2572,16 @@ class IncrementalDateTime(object):
2572
2573
2574 class GitoliteEnvironment(
2119 - ProjectdescEnvironmentMixin,
2120 - ConfigMaxlinesEnvironmentMixin,
2121 - ComputeFQDNEnvironmentMixin,
2122 - ConfigFilterLinesEnvironmentMixin,
2123 - ConfigRecipientsEnvironmentMixin,
2124 - PusherDomainEnvironmentMixin,
2125 - ConfigOptionsEnvironmentMixin,
2126 - GitoliteEnvironmentMixin,
2127 - Environment,
2128 - ):
2575 + ProjectdescEnvironmentMixin,
2576 + ConfigMaxlinesEnvironmentMixin,
2577 + ComputeFQDNEnvironmentMixin,
2578 + ConfigFilterLinesEnvironmentMixin,
2579 + ConfigRecipientsEnvironmentMixin,
2580 + PusherDomainEnvironmentMixin,
2581 + ConfigOptionsEnvironmentMixin,
2582 + GitoliteEnvironmentMixin,
2583 + Environment,
2584 + ):
2585 pass
2586
2587
@@ -2149,9 +2605,9 @@ class Push(object):
2605 references.
2606
2607 The first step is to determine the "other" references--those
2152 - unaffected by the current push. They are computed by
2153 - Push._compute_other_ref_sha1s() by listing all references then
2154 - removing any affected by this push.
2608 + unaffected by the current push. They are computed by listing all
2609 + references then removing any affected by this push. The results
2610 + are stored in Push._other_ref_sha1s.
2611
2612 The commits contained in the repository before this push were
2613
@@ -2187,7 +2643,7 @@ class Push(object):
2643 possible and working with SHA1s thereafter (because SHA1s are
2644 immutable)."""
2645
2190 - # A map {(changeclass, changetype) : integer} specifying the order
2646 + # A map {(changeclass, changetype): integer} specifying the order
2647 # that reference changes will be processed if multiple reference
2648 # changes are included in a single push. The order is significant
2649 # mostly because new commit notifications are threaded together
@@ -2211,66 +2667,134 @@ class Push(object):
2667 ])
2668 )
2669
2214 - def __init__(self, changes):
2670 + def __init__(self, changes, ignore_other_refs=False):
2671 self.changes = sorted(changes, key=self._sort_key)
2672 + self.__other_ref_sha1s = None
2673 + self.__cached_commits_spec = {}
2674
2217 - # The SHA-1s of commits referred to by references unaffected
2218 - # by this push:
2219 - other_ref_sha1s = self._compute_other_ref_sha1s()
2675 + if ignore_other_refs:
2676 + self.__other_ref_sha1s = set()
2677
2221 - self._old_rev_exclusion_spec = self._compute_rev_exclusion_spec(
2222 - other_ref_sha1s.union(
2223 - change.old.sha1
2678 + @classmethod
2679 + def _sort_key(klass, change):
2680 + return (klass.SORT_ORDER[change.__class__, change.change_type], change.refname,)
2681 +
2682 + @property
2683 + def _other_ref_sha1s(self):
2684 + """The GitObjects referred to by references unaffected by this push.
2685 + """
2686 + if self.__other_ref_sha1s is None:
2687 + # The refnames being changed by this push:
2688 + updated_refs = set(
2689 + change.refname
2690 for change in self.changes
2225 - if change.old.type in ['commit', 'tag']
2691 )
2227 - )
2228 - self._new_rev_exclusion_spec = self._compute_rev_exclusion_spec(
2229 - other_ref_sha1s.union(
2230 - change.new.sha1
2231 - for change in self.changes
2232 - if change.new.type in ['commit', 'tag']
2692 +
2693 + # The SHA-1s of commits referred to by all references in this
2694 + # repository *except* updated_refs:
2695 + sha1s = set()
2696 + fmt = (
2697 + '%(objectname) %(objecttype) %(refname)\n'
2698 + '%(*objectname) %(*objecttype) %(refname)'
2699 )
2234 - )
2700 + for line in read_git_lines(
2701 + ['for-each-ref', '--format=%s' % (fmt,)]):
2702 + (sha1, type, name) = line.split(' ', 2)
2703 + if sha1 and type == 'commit' and name not in updated_refs:
2704 + sha1s.add(sha1)
2705
2236 - @classmethod
2237 - def _sort_key(klass, change):
2238 - return (klass.SORT_ORDER[change.__class__, change.change_type], change.refname,)
2706 + self.__other_ref_sha1s = sha1s
2707 +
2708 + return self.__other_ref_sha1s
2709 +
2710 + def _get_commits_spec_incl(self, new_or_old, reference_change=None):
2711 + """Get new or old SHA-1 from one or each of the changed refs.
2712
2240 - def _compute_other_ref_sha1s(self):
2241 - """Return the GitObjects referred to by references unaffected by this push."""
2713 + Return a list of SHA-1 commit identifier strings suitable as
2714 + arguments to 'git rev-list' (or 'git log' or ...). The
2715 + returned identifiers are either the old or new values from one
2716 + or all of the changed references, depending on the values of
2717 + new_or_old and reference_change.
2718
2243 - # The refnames being changed by this push:
2244 - updated_refs = set(
2245 - change.refname
2719 + new_or_old is either the string 'new' or the string 'old'. If
2720 + 'new', the returned SHA-1 identifiers are the new values from
2721 + each changed reference. If 'old', the SHA-1 identifiers are
2722 + the old values from each changed reference.
2723 +
2724 + If reference_change is specified and not None, only the new or
2725 + old reference from the specified reference is included in the
2726 + return value.
2727 +
2728 + This function returns None if there are no matching revisions
2729 + (e.g., because a branch was deleted and new_or_old is 'new').
2730 + """
2731 +
2732 + if not reference_change:
2733 + incl_spec = sorted(
2734 + getattr(change, new_or_old).sha1
2735 + for change in self.changes
2736 + if getattr(change, new_or_old)
2737 + )
2738 + if not incl_spec:
2739 + incl_spec = None
2740 + elif not getattr(reference_change, new_or_old).commit_sha1:
2741 + incl_spec = None
2742 + else:
2743 + incl_spec = [getattr(reference_change, new_or_old).commit_sha1]
2744 + return incl_spec
2745 +
2746 + def _get_commits_spec_excl(self, new_or_old):
2747 + """Get exclusion revisions for determining new or discarded commits.
2748 +
2749 + Return a list of strings suitable as arguments to 'git
2750 + rev-list' (or 'git log' or ...) that will exclude all
2751 + commits that, depending on the value of new_or_old, were
2752 + either previously in the repository (useful for determining
2753 + which commits are new to the repository) or currently in the
2754 + repository (useful for determining which commits were
2755 + discarded from the repository).
2756 +
2757 + new_or_old is either the string 'new' or the string 'old'. If
2758 + 'new', the commits to be excluded are those that were in the
2759 + repository before the push. If 'old', the commits to be
2760 + excluded are those that are currently in the repository. """
2761 +
2762 + old_or_new = {'old': 'new', 'new': 'old'}[new_or_old]
2763 + excl_revs = self._other_ref_sha1s.union(
2764 + getattr(change, old_or_new).sha1
2765 for change in self.changes
2766 + if getattr(change, old_or_new).type in ['commit', 'tag']
2767 )
2768 + return ['^' + sha1 for sha1 in sorted(excl_revs)]
2769
2249 - # The SHA-1s of commits referred to by all references in this
2250 - # repository *except* updated_refs:
2251 - sha1s = set()
2252 - fmt = (
2253 - '%(objectname) %(objecttype) %(refname)\n'
2254 - '%(*objectname) %(*objecttype) %(refname)'
2255 - )
2256 - for line in read_git_lines(['for-each-ref', '--format=%s' % (fmt,)]):
2257 - (sha1, type, name) = line.split(' ', 2)
2258 - if sha1 and type == 'commit' and name not in updated_refs:
2259 - sha1s.add(sha1)
2770 + def get_commits_spec(self, new_or_old, reference_change=None):
2771 + """Get rev-list arguments for added or discarded commits.
2772
2261 - return sha1s
2773 + Return a list of strings suitable as arguments to 'git
2774 + rev-list' (or 'git log' or ...) that select those commits
2775 + that, depending on the value of new_or_old, are either new to
2776 + the repository or were discarded from the repository.
2777
2263 - def _compute_rev_exclusion_spec(self, sha1s):
2264 - """Return an exclusion specification for 'git rev-list'.
2778 + new_or_old is either the string 'new' or the string 'old'. If
2779 + 'new', the returned list is used to select commits that are
2780 + new to the repository. If 'old', the returned value is used
2781 + to select the commits that have been discarded from the
2782 + repository.
2783
2266 - git_objects is an iterable over GitObject instances. Return a
2267 - string that can be passed to the standard input of 'git
2268 - rev-list --stdin' to exclude all of the commits referred to by
2269 - git_objects."""
2784 + If reference_change is specified and not None, the new or
2785 + discarded commits are limited to those that are reachable from
2786 + the new or old value of the specified reference.
2787
2271 - return ''.join(
2272 - ['^%s\n' % (sha1,) for sha1 in sorted(sha1s)]
2273 - )
2788 + This function returns None if there are no added (or discarded)
2789 + revisions.
2790 + """
2791 + key = (new_or_old, reference_change)
2792 + if key not in self.__cached_commits_spec:
2793 + ret = self._get_commits_spec_incl(new_or_old, reference_change)
2794 + if ret is not None:
2795 + ret.extend(self._get_commits_spec_excl(new_or_old))
2796 + self.__cached_commits_spec[key] = ret
2797 + return self.__cached_commits_spec[key]
2798
2799 def get_new_commits(self, reference_change=None):
2800 """Return a list of commits added by this push.
@@ -2280,19 +2804,8 @@ class Push(object):
2804 reference_change is None, then return a list of *all* commits
2805 added by this push."""
2806
2283 - if not reference_change:
2284 - new_revs = sorted(
2285 - change.new.sha1
2286 - for change in self.changes
2287 - if change.new
2288 - )
2289 - elif not reference_change.new.commit_sha1:
2290 - return []
2291 - else:
2292 - new_revs = [reference_change.new.commit_sha1]
2293 -
2294 - cmd = ['rev-list', '--stdin'] + new_revs
2295 - return read_git_lines(cmd, input=self._old_rev_exclusion_spec)
2807 + spec = self.get_commits_spec('new', reference_change)
2808 + return git_rev_list(spec)
2809
2810 def get_discarded_commits(self, reference_change):
2811 """Return a list of commits discarded by this push.
@@ -2301,13 +2814,8 @@ class Push(object):
2814 entirely discarded from the repository by the part of this
2815 push represented by reference_change."""
2816
2304 - if not reference_change.old.commit_sha1:
2305 - return []
2306 - else:
2307 - old_revs = [reference_change.old.commit_sha1]
2308 -
2309 - cmd = ['rev-list', '--stdin'] + old_revs
2310 - return read_git_lines(cmd, input=self._new_rev_exclusion_spec)
2817 + spec = self.get_commits_spec('old', reference_change)
2818 + return git_rev_list(spec)
2819
2820 def send_emails(self, mailer, body_filter=None):
2821 """Use send all of the notification emails needed for this push.
@@ -2325,30 +2833,43 @@ class Push(object):
2833 unhandled_sha1s = set(self.get_new_commits())
2834 send_date = IncrementalDateTime()
2835 for change in self.changes:
2836 + sha1s = []
2837 + for sha1 in reversed(list(self.get_new_commits(change))):
2838 + if sha1 in unhandled_sha1s:
2839 + sha1s.append(sha1)
2840 + unhandled_sha1s.remove(sha1)
2841 +
2842 # Check if we've got anyone to send to
2843 if not change.recipients:
2330 - sys.stderr.write(
2844 + change.environment.log_warning(
2845 '*** no recipients configured so no email will be sent\n'
2846 '*** for %r update %s->%s\n'
2847 % (change.refname, change.old.sha1, change.new.sha1,)
2848 )
2849 else:
2336 - sys.stderr.write('Sending notification emails to: %s\n' % (change.recipients,))
2337 - extra_values = {'send_date' : send_date.next()}
2338 - mailer.send(
2339 - change.generate_email(self, body_filter, extra_values),
2340 - change.recipients,
2341 - )
2850 + if not change.environment.quiet:
2851 + change.environment.log_msg(
2852 + 'Sending notification emails to: %s\n' % (change.recipients,))
2853 + extra_values = {'send_date': send_date.next()}
2854
2343 - sha1s = []
2344 - for sha1 in reversed(list(self.get_new_commits(change))):
2345 - if sha1 in unhandled_sha1s:
2346 - sha1s.append(sha1)
2347 - unhandled_sha1s.remove(sha1)
2855 + rev = change.send_single_combined_email(sha1s)
2856 + if rev:
2857 + mailer.send(
2858 + change.generate_combined_email(self, rev, body_filter, extra_values),
2859 + rev.recipients,
2860 + )
2861 + # This change is now fully handled; no need to handle
2862 + # individual revisions any further.
2863 + continue
2864 + else:
2865 + mailer.send(
2866 + change.generate_email(self, body_filter, extra_values),
2867 + change.recipients,
2868 + )
2869
2870 max_emails = change.environment.maxcommitemails
2871 if max_emails and len(sha1s) > max_emails:
2351 - sys.stderr.write(
2872 + change.environment.log_warning(
2873 '*** Too many new commits (%d), not sending commit emails.\n' % len(sha1s)
2874 + '*** Try setting multimailhook.maxCommitEmails to a greater value\n'
2875 + '*** Currently, multimailhook.maxCommitEmails=%d\n' % max_emails
@@ -2356,9 +2877,13 @@ class Push(object):
2877 return
2878
2879 for (num, sha1) in enumerate(sha1s):
2359 - rev = Revision(change, GitObject(sha1), num=num+1, tot=len(sha1s))
2880 + rev = Revision(change, GitObject(sha1), num=num + 1, tot=len(sha1s))
2881 + if not rev.recipients and rev.cc_recipients:
2882 + change.environment.log_msg('*** Replacing Cc: with To:\n')
2883 + rev.recipients = rev.cc_recipients
2884 + rev.cc_recipients = None
2885 if rev.recipients:
2361 - extra_values = {'send_date' : send_date.next()}
2886 + extra_values = {'send_date': send_date.next()}
2887 mailer.send(
2888 rev.generate_email(self, body_filter, extra_values),
2889 rev.recipients,
@@ -2366,7 +2891,7 @@ class Push(object):
2891
2892 # Consistency check:
2893 if unhandled_sha1s:
2369 - sys.stderr.write(
2894 + change.environment.log_error(
2895 'ERROR: No emails were sent for the following new commits:\n'
2896 ' %s\n'
2897 % ('\n '.join(sorted(unhandled_sha1s)),)
@@ -2384,7 +2909,7 @@ def run_as_post_receive_hook(environment, mailer):
2909 push.send_emails(mailer, body_filter=environment.filter_body)
2910
2911
2387 -def run_as_update_hook(environment, mailer, refname, oldrev, newrev):
2912 +def run_as_update_hook(environment, mailer, refname, oldrev, newrev, force_send=False):
2913 changes = [
2914 ReferenceChange.create(
2915 environment,
@@ -2393,7 +2918,7 @@ def run_as_update_hook(environment, mailer, refname, oldrev, newrev):
2918 refname,
2919 ),
2920 ]
2396 - push = Push(changes)
2921 + push = Push(changes, force_send)
2922 push.send_emails(mailer, body_filter=environment.filter_body)
2923
2924
@@ -2402,9 +2927,18 @@ def choose_mailer(config, environment):
2927
2928 if mailer == 'smtp':
2929 smtpserver = config.get('smtpserver', default='localhost')
2930 + smtpservertimeout = float(config.get('smtpservertimeout', default=10.0))
2931 + smtpserverdebuglevel = int(config.get('smtpserverdebuglevel', default=0))
2932 + smtpencryption = config.get('smtpencryption', default='none')
2933 + smtpuser = config.get('smtpuser', default='')
2934 + smtppass = config.get('smtppass', default='')
2935 mailer = SMTPMailer(
2936 envelopesender=(environment.get_sender() or environment.get_fromaddr()),
2407 - smtpserver=smtpserver,
2937 + smtpserver=smtpserver, smtpservertimeout=smtpservertimeout,
2938 + smtpserverdebuglevel=smtpserverdebuglevel,
2939 + smtpencryption=smtpencryption,
2940 + smtpuser=smtpuser,
2941 + smtppass=smtppass,
2942 )
2943 elif mailer == 'sendmail':
2944 command = config.get('sendmailcommand')
@@ -2412,7 +2946,7 @@ def choose_mailer(config, environment):
2946 command = shlex.split(command)
2947 mailer = SendMailer(command=command, envelopesender=environment.get_sender())
2948 else:
2415 - sys.stderr.write(
2949 + environment.log_error(
2950 'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n' % mailer
2951 + 'please use one of "smtp" or "sendmail".\n'
2952 )
@@ -2421,8 +2955,8 @@ def choose_mailer(config, environment):
2955
2956
2957 KNOWN_ENVIRONMENTS = {
2424 - 'generic' : GenericEnvironmentMixin,
2425 - 'gitolite' : GitoliteEnvironmentMixin,
2958 + 'generic': GenericEnvironmentMixin,
2959 + 'gitolite': GitoliteEnvironmentMixin,
2960 }
2961
2962
@@ -2439,8 +2973,8 @@ def choose_environment(config, osenv=None, env=None, recipients=None):
2973 ConfigOptionsEnvironmentMixin,
2974 ]
2975 environment_kw = {
2442 - 'osenv' : osenv,
2443 - 'config' : config,
2976 + 'osenv': osenv,
2977 + 'config': config,
2978 }
2979
2980 if not env:
@@ -2459,6 +2993,7 @@ def choose_environment(config, osenv=None, env=None, recipients=None):
2993 environment_kw['refchange_recipients'] = recipients
2994 environment_kw['announce_recipients'] = recipients
2995 environment_kw['revision_recipients'] = recipients
2996 + environment_kw['scancommitforcc'] = config.get('scancommitforcc')
2997 else:
2998 environment_mixins.insert(0, ConfigRecipientsEnvironmentMixin)
2999
@@ -2499,6 +3034,14 @@ def main(args):
3034 '(intended for debugging purposes).'
3035 ),
3036 )
3037 + parser.add_option(
3038 + '--force-send', action='store_true', default=False,
3039 + help=(
3040 + 'Force sending refchange email when using as an update hook. '
3041 + 'This is useful to work around the unreliable new commits '
3042 + 'detection in this mode.'
3043 + ),
3044 + )
3045
3046 (options, args) = parser.parse_args(args)
3047
@@ -2513,11 +3056,11 @@ def main(args):
3056
3057 if options.show_env:
3058 sys.stderr.write('Environment values:\n')
2516 - for (k,v) in sorted(environment.get_values().items()):
2517 - sys.stderr.write(' %s : %r\n' % (k,v))
3059 + for (k, v) in sorted(environment.get_values().items()):
3060 + sys.stderr.write(' %s : %r\n' % (k, v))
3061 sys.stderr.write('\n')
3062
2520 - if options.stdout:
3063 + if options.stdout or environment.stdout:
3064 mailer = OutputMailer(sys.stdout)
3065 else:
3066 mailer = choose_mailer(config, environment)
@@ -2528,7 +3071,7 @@ def main(args):
3071 if len(args) != 3:
3072 parser.error('Need zero or three non-option arguments')
3073 (refname, oldrev, newrev) = args
2531 - run_as_update_hook(environment, mailer, refname, oldrev, newrev)
3074 + run_as_update_hook(environment, mailer, refname, oldrev, newrev, options.force_send)
3075 else:
3076 run_as_post_receive_hook(environment, mailer)
3077 except ConfigurationException, e:
contrib/hooks/multimail/migrate-mailhook-config
+10 -8
@@ -22,6 +22,7 @@ OLD_NAMES = [
22 'showrev',
23 'emailmaxlines',
24 'diffopts',
25 + 'scancommitforcc',
26 ]
27
28 NEW_NAMES = [
@@ -38,6 +39,7 @@ NEW_NAMES = [
39 'emailmaxlines',
40 'diffopts',
41 'emaildomain',
42 + 'scancommitforcc',
43 ]
44
45
@@ -61,7 +63,7 @@ def _check_old_config_exists(old):
63 """Check that at least one old configuration value is set."""
64
65 for name in OLD_NAMES:
64 - if old.has_key(name):
66 + if name in old:
67 return True
68
69 return False
@@ -72,7 +74,7 @@ def _check_new_config_clear(new):
74
75 retval = True
76 for name in NEW_NAMES:
75 - if new.has_key(name):
77 + if name in new:
78 if retval:
79 sys.stderr.write('INFO: The following configuration values already exist:\n\n')
80 sys.stderr.write(' "%s.%s"\n' % (new.section, name))
@@ -83,7 +85,7 @@ def _check_new_config_clear(new):
85
86 def erase_values(config, names):
87 for name in names:
86 - if config.has_key(name):
88 + if name in config:
89 try:
90 sys.stderr.write('...unsetting "%s.%s"\n' % (config.section, name))
91 config.unset_all(name)
@@ -170,7 +172,7 @@ def migrate_config(strict=False, retain=False, overwrite=False):
172 )
173
174 name = 'showrev'
173 - if old.has_key(name):
175 + if name in old:
176 msg = 'git-multimail does not support "%s.%s"' % (old.section, name,)
177 if strict:
178 sys.exit(
@@ -182,7 +184,7 @@ def migrate_config(strict=False, retain=False, overwrite=False):
184 sys.stderr.write('\nWARNING: %s (ignoring).\n\n' % (msg,))
185
186 for name in ['mailinglist', 'announcelist']:
185 - if old.has_key(name):
187 + if name in old:
188 sys.stderr.write(
189 '...copying "%s.%s" to "%s.%s"\n' % (old.section, name, new.section, name)
190 )
@@ -198,15 +200,15 @@ def migrate_config(strict=False, retain=False, overwrite=False):
200 )
201 new.set('announceshortlog', 'true')
202
201 - for name in ['envelopesender', 'emailmaxlines', 'diffopts']:
202 - if old.has_key(name):
203 + for name in ['envelopesender', 'emailmaxlines', 'diffopts', 'scancommitforcc']:
204 + if name in old:
205 sys.stderr.write(
206 '...copying "%s.%s" to "%s.%s"\n' % (old.section, name, new.section, name)
207 )
208 new.set(name, old.get(name))
209
210 name = 'emailprefix'
209 - if old.has_key(name):
211 + if name in old:
212 sys.stderr.write(
213 '...copying "%s.%s" to "%s.%s"\n' % (old.section, name, new.section, name)
214 )
contrib/hooks/multimail/post-receive.example renamed
+16 -11
@@ -2,16 +2,18 @@
2
3 """Example post-receive hook based on git-multimail.
4
5 -This script is a simple example of a post-receive hook implemented
6 -using git_multimail.py as a Python module. It is intended to be
7 -customized before use; see the comments in the script to help you get
8 -started.
5 +The simplest way to use git-multimail is to use the script
6 +git_multimail.py directly as a post-receive hook, and to configure it
7 +using Git's configuration files and command-line parameters. You can
8 +also write your own Python wrapper for more advanced configurability,
9 +using git_multimail.py as a Python module.
10
10 -It is possible to use git_multimail.py itself as a post-receive or
11 -update hook, configured via git config settings and/or command-line
12 -parameters. But for more flexibility, it can also be imported as a
13 -Python module by a custom post-receive script as done here. The
14 -latter has the following advantages:
11 +This script is a simple example of such a post-receive hook. It is
12 +intended to be customized before use; see the comments in the script
13 +to help you get started.
14 +
15 +Using git-multimail as a Python module as done here provides more
16 +flexibility. It has the following advantages:
17
18 * The tool's behavior can be customized using arbitrary Python code,
19 without having to edit git_multimail.py.
@@ -56,8 +58,11 @@ config = git_multimail.Config('multimailhook')
58
59
60 # Select the type of environment:
59 -environment = git_multimail.GenericEnvironment(config=config)
60 -#environment = git_multimail.GitoliteEnvironment(config=config)
61 +try:
62 + environment = git_multimail.GenericEnvironment(config=config)
63 + #environment = git_multimail.GitoliteEnvironment(config=config)
64 +except git_multimail.ConfigurationException, e:
65 + sys.exit(str(e))
66
67
68 # Choose the method of sending emails based on the git config: