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