Git 2.7.3
[git/debian.git] / contrib / hooks / multimail / git_multimail.py
blob0180dba43126df209bde82e3e5aea3616f402c96
1 #! /usr/bin/env python
3 __version__ = '1.2.0'
5 # Copyright (c) 2015 Matthieu Moy and others
6 # Copyright (c) 2012-2014 Michael Haggerty and others
7 # Derived from contrib/hooks/post-receive-email, which is
8 # Copyright (c) 2007 Andy Parkins
9 # and also includes contributions by other authors.
11 # This file is part of git-multimail.
13 # git-multimail is free software: you can redistribute it and/or
14 # modify it under the terms of the GNU General Public License version
15 # 2 as published by the Free Software Foundation.
17 # This program is distributed in the hope that it will be useful, but
18 # WITHOUT ANY WARRANTY; without even the implied warranty of
19 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
20 # General Public License for more details.
22 # You should have received a copy of the GNU General Public License
23 # along with this program. If not, see
24 # <http://www.gnu.org/licenses/>.
26 """Generate notification emails for pushes to a git repository.
28 This hook sends emails describing changes introduced by pushes to a
29 git repository. For each reference that was changed, it emits one
30 ReferenceChange email summarizing how the reference was changed,
31 followed by one Revision email for each new commit that was introduced
32 by the reference change.
34 Each commit is announced in exactly one Revision email. If the same
35 commit is merged into another branch in the same or a later push, then
36 the ReferenceChange email will list the commit's SHA1 and its one-line
37 summary, but no new Revision email will be generated.
39 This script is designed to be used as a "post-receive" hook in a git
40 repository (see githooks(5)). It can also be used as an "update"
41 script, but this usage is not completely reliable and is deprecated.
43 To help with debugging, this script accepts a --stdout option, which
44 causes the emails to be written to standard output rather than sent
45 using sendmail.
47 See the accompanying README file for the complete documentation.
49 """
51 import sys
52 import os
53 import re
54 import bisect
55 import socket
56 import subprocess
57 import shlex
58 import optparse
59 import smtplib
60 import time
61 import cgi
63 PYTHON3 = sys.version_info >= (3, 0)
65 if sys.version_info <= (2, 5):
66 def all(iterable):
67 for element in iterable:
68 if not element:
69 return False
70 return True
73 def is_ascii(s):
74 return all(ord(c) < 128 and ord(c) > 0 for c in s)
77 if PYTHON3:
78 def str_to_bytes(s):
79 return s.encode(ENCODING)
81 def bytes_to_str(s):
82 return s.decode(ENCODING)
84 unicode = str
86 def write_str(f, msg):
87 # Try outputing with the default encoding. If it fails,
88 # try UTF-8.
89 try:
90 f.buffer.write(msg.encode(sys.getdefaultencoding()))
91 except UnicodeEncodeError:
92 f.buffer.write(msg.encode(ENCODING))
93 else:
94 def str_to_bytes(s):
95 return s
97 def bytes_to_str(s):
98 return s
100 def write_str(f, msg):
101 f.write(msg)
103 def next(it):
104 return it.next()
107 try:
108 from email.charset import Charset
109 from email.utils import make_msgid
110 from email.utils import getaddresses
111 from email.utils import formataddr
112 from email.utils import formatdate
113 from email.header import Header
114 except ImportError:
115 # Prior to Python 2.5, the email module used different names:
116 from email.Charset import Charset
117 from email.Utils import make_msgid
118 from email.Utils import getaddresses
119 from email.Utils import formataddr
120 from email.Utils import formatdate
121 from email.Header import Header
124 DEBUG = False
126 ZEROS = '0' * 40
127 LOGBEGIN = '- Log -----------------------------------------------------------------\n'
128 LOGEND = '-----------------------------------------------------------------------\n'
130 ADDR_HEADERS = set(['from', 'to', 'cc', 'bcc', 'reply-to', 'sender'])
132 # It is assumed in many places that the encoding is uniformly UTF-8,
133 # so changing these constants is unsupported. But define them here
134 # anyway, to make it easier to find (at least most of) the places
135 # where the encoding is important.
136 (ENCODING, CHARSET) = ('UTF-8', 'utf-8')
139 REF_CREATED_SUBJECT_TEMPLATE = (
140 '%(emailprefix)s%(refname_type)s %(short_refname)s created'
141 ' (now %(newrev_short)s)'
143 REF_UPDATED_SUBJECT_TEMPLATE = (
144 '%(emailprefix)s%(refname_type)s %(short_refname)s updated'
145 ' (%(oldrev_short)s -> %(newrev_short)s)'
147 REF_DELETED_SUBJECT_TEMPLATE = (
148 '%(emailprefix)s%(refname_type)s %(short_refname)s deleted'
149 ' (was %(oldrev_short)s)'
152 COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE = (
153 '%(emailprefix)s%(refname_type)s %(short_refname)s updated: %(oneline)s'
156 REFCHANGE_HEADER_TEMPLATE = """\
157 Date: %(send_date)s
158 To: %(recipients)s
159 Subject: %(subject)s
160 MIME-Version: 1.0
161 Content-Type: text/%(contenttype)s; charset=%(charset)s
162 Content-Transfer-Encoding: 8bit
163 Message-ID: %(msgid)s
164 From: %(fromaddr)s
165 Reply-To: %(reply_to)s
166 X-Git-Host: %(fqdn)s
167 X-Git-Repo: %(repo_shortname)s
168 X-Git-Refname: %(refname)s
169 X-Git-Reftype: %(refname_type)s
170 X-Git-Oldrev: %(oldrev)s
171 X-Git-Newrev: %(newrev)s
172 X-Git-NotificationType: ref_changed
173 X-Git-Multimail-Version: %(multimail_version)s
174 Auto-Submitted: auto-generated
177 REFCHANGE_INTRO_TEMPLATE = """\
178 This is an automated email from the git hooks/post-receive script.
180 %(pusher)s pushed a change to %(refname_type)s %(short_refname)s
181 in repository %(repo_shortname)s.
186 FOOTER_TEMPLATE = """\
188 -- \n\
189 To stop receiving notification emails like this one, please contact
190 %(administrator)s.
194 REWIND_ONLY_TEMPLATE = """\
195 This update removed existing revisions from the reference, leaving the
196 reference pointing at a previous point in the repository history.
198 * -- * -- N %(refname)s (%(newrev_short)s)
200 O -- O -- O (%(oldrev_short)s)
202 Any revisions marked "omits" are not gone; other references still
203 refer to them. Any revisions marked "discards" are gone forever.
207 NON_FF_TEMPLATE = """\
208 This update added new revisions after undoing existing revisions.
209 That is to say, some revisions that were in the old version of the
210 %(refname_type)s are not in the new version. This situation occurs
211 when a user --force pushes a change and generates a repository
212 containing something like this:
214 * -- * -- B -- O -- O -- O (%(oldrev_short)s)
216 N -- N -- N %(refname)s (%(newrev_short)s)
218 You should already have received notification emails for all of the O
219 revisions, and so the following emails describe only the N revisions
220 from the common base, B.
222 Any revisions marked "omits" are not gone; other references still
223 refer to them. Any revisions marked "discards" are gone forever.
227 NO_NEW_REVISIONS_TEMPLATE = """\
228 No new revisions were added by this update.
232 DISCARDED_REVISIONS_TEMPLATE = """\
233 This change permanently discards the following revisions:
237 NO_DISCARDED_REVISIONS_TEMPLATE = """\
238 The revisions that were on this %(refname_type)s are still contained in
239 other references; therefore, this change does not discard any commits
240 from the repository.
244 NEW_REVISIONS_TEMPLATE = """\
245 The %(tot)s revisions listed above as "new" are entirely new to this
246 repository and will be described in separate emails. The revisions
247 listed as "adds" were already present in the repository and have only
248 been added to this reference.
253 TAG_CREATED_TEMPLATE = """\
254 at %(newrev_short)-9s (%(newrev_type)s)
258 TAG_UPDATED_TEMPLATE = """\
259 *** WARNING: tag %(short_refname)s was modified! ***
261 from %(oldrev_short)-9s (%(oldrev_type)s)
262 to %(newrev_short)-9s (%(newrev_type)s)
266 TAG_DELETED_TEMPLATE = """\
267 *** WARNING: tag %(short_refname)s was deleted! ***
272 # The template used in summary tables. It looks best if this uses the
273 # same alignment as TAG_CREATED_TEMPLATE and TAG_UPDATED_TEMPLATE.
274 BRIEF_SUMMARY_TEMPLATE = """\
275 %(action)10s %(rev_short)-9s %(text)s
279 NON_COMMIT_UPDATE_TEMPLATE = """\
280 This is an unusual reference change because the reference did not
281 refer to a commit either before or after the change. We do not know
282 how to provide full information about this reference change.
286 REVISION_HEADER_TEMPLATE = """\
287 Date: %(send_date)s
288 To: %(recipients)s
289 Cc: %(cc_recipients)s
290 Subject: %(emailprefix)s%(num)02d/%(tot)02d: %(oneline)s
291 MIME-Version: 1.0
292 Content-Type: text/%(contenttype)s; charset=%(charset)s
293 Content-Transfer-Encoding: 8bit
294 From: %(fromaddr)s
295 Reply-To: %(reply_to)s
296 In-Reply-To: %(reply_to_msgid)s
297 References: %(reply_to_msgid)s
298 X-Git-Host: %(fqdn)s
299 X-Git-Repo: %(repo_shortname)s
300 X-Git-Refname: %(refname)s
301 X-Git-Reftype: %(refname_type)s
302 X-Git-Rev: %(rev)s
303 X-Git-NotificationType: diff
304 X-Git-Multimail-Version: %(multimail_version)s
305 Auto-Submitted: auto-generated
308 REVISION_INTRO_TEMPLATE = """\
309 This is an automated email from the git hooks/post-receive script.
311 %(pusher)s pushed a commit to %(refname_type)s %(short_refname)s
312 in repository %(repo_shortname)s.
317 REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE
320 # Combined, meaning refchange+revision email (for single-commit additions)
321 COMBINED_HEADER_TEMPLATE = """\
322 Date: %(send_date)s
323 To: %(recipients)s
324 Subject: %(subject)s
325 MIME-Version: 1.0
326 Content-Type: text/%(contenttype)s; charset=%(charset)s
327 Content-Transfer-Encoding: 8bit
328 Message-ID: %(msgid)s
329 From: %(fromaddr)s
330 Reply-To: %(reply_to)s
331 X-Git-Host: %(fqdn)s
332 X-Git-Repo: %(repo_shortname)s
333 X-Git-Refname: %(refname)s
334 X-Git-Reftype: %(refname_type)s
335 X-Git-Oldrev: %(oldrev)s
336 X-Git-Newrev: %(newrev)s
337 X-Git-Rev: %(rev)s
338 X-Git-NotificationType: ref_changed_plus_diff
339 X-Git-Multimail-Version: %(multimail_version)s
340 Auto-Submitted: auto-generated
343 COMBINED_INTRO_TEMPLATE = """\
344 This is an automated email from the git hooks/post-receive script.
346 %(pusher)s pushed a commit to %(refname_type)s %(short_refname)s
347 in repository %(repo_shortname)s.
351 COMBINED_FOOTER_TEMPLATE = FOOTER_TEMPLATE
354 class CommandError(Exception):
355 def __init__(self, cmd, retcode):
356 self.cmd = cmd
357 self.retcode = retcode
358 Exception.__init__(
359 self,
360 'Command "%s" failed with retcode %s' % (' '.join(cmd), retcode,)
364 class ConfigurationException(Exception):
365 pass
368 # The "git" program (this could be changed to include a full path):
369 GIT_EXECUTABLE = 'git'
372 # How "git" should be invoked (including global arguments), as a list
373 # of words. This variable is usually initialized automatically by
374 # read_git_output() via choose_git_command(), but if a value is set
375 # here then it will be used unconditionally.
376 GIT_CMD = None
379 def choose_git_command():
380 """Decide how to invoke git, and record the choice in GIT_CMD."""
382 global GIT_CMD
384 if GIT_CMD is None:
385 try:
386 # Check to see whether the "-c" option is accepted (it was
387 # only added in Git 1.7.2). We don't actually use the
388 # output of "git --version", though if we needed more
389 # specific version information this would be the place to
390 # do it.
391 cmd = [GIT_EXECUTABLE, '-c', 'foo.bar=baz', '--version']
392 read_output(cmd)
393 GIT_CMD = [GIT_EXECUTABLE, '-c', 'i18n.logoutputencoding=%s' % (ENCODING,)]
394 except CommandError:
395 GIT_CMD = [GIT_EXECUTABLE]
398 def read_git_output(args, input=None, keepends=False, **kw):
399 """Read the output of a Git command."""
401 if GIT_CMD is None:
402 choose_git_command()
404 return read_output(GIT_CMD + args, input=input, keepends=keepends, **kw)
407 def read_output(cmd, input=None, keepends=False, **kw):
408 if input:
409 stdin = subprocess.PIPE
410 input = str_to_bytes(input)
411 else:
412 stdin = None
413 p = subprocess.Popen(
414 cmd, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw
416 (out, err) = p.communicate(input)
417 out = bytes_to_str(out)
418 retcode = p.wait()
419 if retcode:
420 raise CommandError(cmd, retcode)
421 if not keepends:
422 out = out.rstrip('\n\r')
423 return out
426 def read_git_lines(args, keepends=False, **kw):
427 """Return the lines output by Git command.
429 Return as single lines, with newlines stripped off."""
431 return read_git_output(args, keepends=True, **kw).splitlines(keepends)
434 def git_rev_list_ish(cmd, spec, args=None, **kw):
435 """Common functionality for invoking a 'git rev-list'-like command.
437 Parameters:
438 * cmd is the Git command to run, e.g., 'rev-list' or 'log'.
439 * spec is a list of revision arguments to pass to the named
440 command. If None, this function returns an empty list.
441 * args is a list of extra arguments passed to the named command.
442 * All other keyword arguments (if any) are passed to the
443 underlying read_git_lines() function.
445 Return the output of the Git command in the form of a list, one
446 entry per output line.
448 if spec is None:
449 return []
450 if args is None:
451 args = []
452 args = [cmd, '--stdin'] + args
453 spec_stdin = ''.join(s + '\n' for s in spec)
454 return read_git_lines(args, input=spec_stdin, **kw)
457 def git_rev_list(spec, **kw):
458 """Run 'git rev-list' with the given list of revision arguments.
460 See git_rev_list_ish() for parameter and return value
461 documentation.
463 return git_rev_list_ish('rev-list', spec, **kw)
466 def git_log(spec, **kw):
467 """Run 'git log' with the given list of revision arguments.
469 See git_rev_list_ish() for parameter and return value
470 documentation.
472 return git_rev_list_ish('log', spec, **kw)
475 def header_encode(text, header_name=None):
476 """Encode and line-wrap the value of an email header field."""
478 # Convert to unicode, if required.
479 if not isinstance(text, unicode):
480 text = unicode(text, 'utf-8')
482 if is_ascii(text):
483 charset = 'ascii'
484 else:
485 charset = 'utf-8'
487 return Header(text, header_name=header_name, charset=Charset(charset)).encode()
490 def addr_header_encode(text, header_name=None):
491 """Encode and line-wrap the value of an email header field containing
492 email addresses."""
494 # Convert to unicode, if required.
495 if not isinstance(text, unicode):
496 text = unicode(text, 'utf-8')
498 text = ', '.join(
499 formataddr((header_encode(name), emailaddr))
500 for name, emailaddr in getaddresses([text])
503 if is_ascii(text):
504 charset = 'ascii'
505 else:
506 charset = 'utf-8'
508 return Header(text, header_name=header_name, charset=Charset(charset)).encode()
511 class Config(object):
512 def __init__(self, section, git_config=None):
513 """Represent a section of the git configuration.
515 If git_config is specified, it is passed to "git config" in
516 the GIT_CONFIG environment variable, meaning that "git config"
517 will read the specified path rather than the Git default
518 config paths."""
520 self.section = section
521 if git_config:
522 self.env = os.environ.copy()
523 self.env['GIT_CONFIG'] = git_config
524 else:
525 self.env = None
527 @staticmethod
528 def _split(s):
529 """Split NUL-terminated values."""
531 words = s.split('\0')
532 assert words[-1] == ''
533 return words[:-1]
535 def get(self, name, default=None):
536 try:
537 values = self._split(read_git_output(
538 ['config', '--get', '--null', '%s.%s' % (self.section, name)],
539 env=self.env, keepends=True,
541 assert len(values) == 1
542 return values[0]
543 except CommandError:
544 return default
546 def get_bool(self, name, default=None):
547 try:
548 value = read_git_output(
549 ['config', '--get', '--bool', '%s.%s' % (self.section, name)],
550 env=self.env,
552 except CommandError:
553 return default
554 return value == 'true'
556 def get_all(self, name, default=None):
557 """Read a (possibly multivalued) setting from the configuration.
559 Return the result as a list of values, or default if the name
560 is unset."""
562 try:
563 return self._split(read_git_output(
564 ['config', '--get-all', '--null', '%s.%s' % (self.section, name)],
565 env=self.env, keepends=True,
567 except CommandError:
568 t, e, traceback = sys.exc_info()
569 if e.retcode == 1:
570 # "the section or key is invalid"; i.e., there is no
571 # value for the specified key.
572 return default
573 else:
574 raise
576 def set(self, name, value):
577 read_git_output(
578 ['config', '%s.%s' % (self.section, name), value],
579 env=self.env,
582 def add(self, name, value):
583 read_git_output(
584 ['config', '--add', '%s.%s' % (self.section, name), value],
585 env=self.env,
588 def __contains__(self, name):
589 return self.get_all(name, default=None) is not None
591 # We don't use this method anymore internally, but keep it here in
592 # case somebody is calling it from their own code:
593 def has_key(self, name):
594 return name in self
596 def unset_all(self, name):
597 try:
598 read_git_output(
599 ['config', '--unset-all', '%s.%s' % (self.section, name)],
600 env=self.env,
602 except CommandError:
603 t, e, traceback = sys.exc_info()
604 if e.retcode == 5:
605 # The name doesn't exist, which is what we wanted anyway...
606 pass
607 else:
608 raise
610 def set_recipients(self, name, value):
611 self.unset_all(name)
612 for pair in getaddresses([value]):
613 self.add(name, formataddr(pair))
616 def generate_summaries(*log_args):
617 """Generate a brief summary for each revision requested.
619 log_args are strings that will be passed directly to "git log" as
620 revision selectors. Iterate over (sha1_short, subject) for each
621 commit specified by log_args (subject is the first line of the
622 commit message as a string without EOLs)."""
624 cmd = [
625 'log', '--abbrev', '--format=%h %s',
626 ] + list(log_args) + ['--']
627 for line in read_git_lines(cmd):
628 yield tuple(line.split(' ', 1))
631 def limit_lines(lines, max_lines):
632 for (index, line) in enumerate(lines):
633 if index < max_lines:
634 yield line
636 if index >= max_lines:
637 yield '... %d lines suppressed ...\n' % (index + 1 - max_lines,)
640 def limit_linelength(lines, max_linelength):
641 for line in lines:
642 # Don't forget that lines always include a trailing newline.
643 if len(line) > max_linelength + 1:
644 line = line[:max_linelength - 7] + ' [...]\n'
645 yield line
648 class CommitSet(object):
649 """A (constant) set of object names.
651 The set should be initialized with full SHA1 object names. The
652 __contains__() method returns True iff its argument is an
653 abbreviation of any the names in the set."""
655 def __init__(self, names):
656 self._names = sorted(names)
658 def __len__(self):
659 return len(self._names)
661 def __contains__(self, sha1_abbrev):
662 """Return True iff this set contains sha1_abbrev (which might be abbreviated)."""
664 i = bisect.bisect_left(self._names, sha1_abbrev)
665 return i < len(self) and self._names[i].startswith(sha1_abbrev)
668 class GitObject(object):
669 def __init__(self, sha1, type=None):
670 if sha1 == ZEROS:
671 self.sha1 = self.type = self.commit_sha1 = None
672 else:
673 self.sha1 = sha1
674 self.type = type or read_git_output(['cat-file', '-t', self.sha1])
676 if self.type == 'commit':
677 self.commit_sha1 = self.sha1
678 elif self.type == 'tag':
679 try:
680 self.commit_sha1 = read_git_output(
681 ['rev-parse', '--verify', '%s^0' % (self.sha1,)]
683 except CommandError:
684 # Cannot deref tag to determine commit_sha1
685 self.commit_sha1 = None
686 else:
687 self.commit_sha1 = None
689 self.short = read_git_output(['rev-parse', '--short', sha1])
691 def get_summary(self):
692 """Return (sha1_short, subject) for this commit."""
694 if not self.sha1:
695 raise ValueError('Empty commit has no summary')
697 return next(iter(generate_summaries('--no-walk', self.sha1)))
699 def __eq__(self, other):
700 return isinstance(other, GitObject) and self.sha1 == other.sha1
702 def __hash__(self):
703 return hash(self.sha1)
705 def __nonzero__(self):
706 return bool(self.sha1)
708 def __bool__(self):
709 """Python 2 backward compatibility"""
710 return self.__nonzero__()
712 def __str__(self):
713 return self.sha1 or ZEROS
716 class Change(object):
717 """A Change that has been made to the Git repository.
719 Abstract class from which both Revisions and ReferenceChanges are
720 derived. A Change knows how to generate a notification email
721 describing itself."""
723 def __init__(self, environment):
724 self.environment = environment
725 self._values = None
726 self._contains_html_diff = False
728 def _contains_diff(self):
729 # We do contain a diff, should it be rendered in HTML?
730 if self.environment.commit_email_format == "html":
731 self._contains_html_diff = True
733 def _compute_values(self):
734 """Return a dictionary {keyword: expansion} for this Change.
736 Derived classes overload this method to add more entries to
737 the return value. This method is used internally by
738 get_values(). The return value should always be a new
739 dictionary."""
741 values = self.environment.get_values()
742 fromaddr = self.environment.get_fromaddr(change=self)
743 if fromaddr is not None:
744 values['fromaddr'] = fromaddr
745 values['multimail_version'] = get_version()
746 return values
748 def get_values(self, **extra_values):
749 """Return a dictionary {keyword: expansion} for this Change.
751 Return a dictionary mapping keywords to the values that they
752 should be expanded to for this Change (used when interpolating
753 template strings). If any keyword arguments are supplied, add
754 those to the return value as well. The return value is always
755 a new dictionary."""
757 if self._values is None:
758 self._values = self._compute_values()
760 values = self._values.copy()
761 if extra_values:
762 values.update(extra_values)
763 return values
765 def expand(self, template, **extra_values):
766 """Expand template.
768 Expand the template (which should be a string) using string
769 interpolation of the values for this Change. If any keyword
770 arguments are provided, also include those in the keywords
771 available for interpolation."""
773 return template % self.get_values(**extra_values)
775 def expand_lines(self, template, **extra_values):
776 """Break template into lines and expand each line."""
778 values = self.get_values(**extra_values)
779 for line in template.splitlines(True):
780 yield line % values
782 def expand_header_lines(self, template, **extra_values):
783 """Break template into lines and expand each line as an RFC 2822 header.
785 Encode values and split up lines that are too long. Silently
786 skip lines that contain references to unknown variables."""
788 values = self.get_values(**extra_values)
789 if self._contains_html_diff:
790 values['contenttype'] = 'html'
791 else:
792 values['contenttype'] = 'plain'
794 for line in template.splitlines():
795 (name, value) = line.split(': ', 1)
797 try:
798 value = value % values
799 except KeyError:
800 t, e, traceback = sys.exc_info()
801 if DEBUG:
802 self.environment.log_warning(
803 'Warning: unknown variable %r in the following line; line skipped:\n'
804 ' %s\n'
805 % (e.args[0], line,)
807 else:
808 if name.lower() in ADDR_HEADERS:
809 value = addr_header_encode(value, name)
810 else:
811 value = header_encode(value, name)
812 for splitline in ('%s: %s\n' % (name, value)).splitlines(True):
813 yield splitline
815 def generate_email_header(self):
816 """Generate the RFC 2822 email headers for this Change, a line at a time.
818 The output should not include the trailing blank line."""
820 raise NotImplementedError()
822 def generate_email_intro(self):
823 """Generate the email intro for this Change, a line at a time.
825 The output will be used as the standard boilerplate at the top
826 of the email body."""
828 raise NotImplementedError()
830 def generate_email_body(self):
831 """Generate the main part of the email body, a line at a time.
833 The text in the body might be truncated after a specified
834 number of lines (see multimailhook.emailmaxlines)."""
836 raise NotImplementedError()
838 def generate_email_footer(self):
839 """Generate the footer of the email, a line at a time.
841 The footer is always included, irrespective of
842 multimailhook.emailmaxlines."""
844 raise NotImplementedError()
846 def _wrap_for_html(self, lines):
847 """Wrap the lines in HTML <pre> tag when using HTML format.
849 Escape special HTML characters and add <pre> and </pre> tags around
850 the given lines if we should be generating HTML as indicated by
851 self._contains_html_diff being set to true.
853 if self._contains_html_diff:
854 yield "<pre style='margin:0'>\n"
856 for line in lines:
857 yield cgi.escape(line)
859 yield '</pre>\n'
860 else:
861 for line in lines:
862 yield line
864 def generate_email(self, push, body_filter=None, extra_header_values={}):
865 """Generate an email describing this change.
867 Iterate over the lines (including the header lines) of an
868 email describing this change. If body_filter is not None,
869 then use it to filter the lines that are intended for the
870 email body.
872 The extra_header_values field is received as a dict and not as
873 **kwargs, to allow passing other keyword arguments in the
874 future (e.g. passing extra values to generate_email_intro()"""
876 for line in self.generate_email_header(**extra_header_values):
877 yield line
878 yield '\n'
879 for line in self._wrap_for_html(self.generate_email_intro()):
880 yield line
882 body = self.generate_email_body(push)
883 if body_filter is not None:
884 body = body_filter(body)
886 diff_started = False
887 if self._contains_html_diff:
888 # "white-space: pre" is the default, but we need to
889 # specify it again in case the message is viewed in a
890 # webmail which wraps it in an element setting white-space
891 # to something else (Zimbra does this and sets
892 # white-space: pre-line).
893 yield '<pre style="white-space: pre; background: #F8F8F8">'
894 for line in body:
895 if self._contains_html_diff:
896 # This is very, very naive. It would be much better to really
897 # parse the diff, i.e. look at how many lines do we have in
898 # the hunk headers instead of blindly highlighting everything
899 # that looks like it might be part of a diff.
900 bgcolor = ''
901 fgcolor = ''
902 if line.startswith('--- a/'):
903 diff_started = True
904 bgcolor = 'e0e0ff'
905 elif line.startswith('diff ') or line.startswith('index '):
906 diff_started = True
907 fgcolor = '808080'
908 elif diff_started:
909 if line.startswith('+++ '):
910 bgcolor = 'e0e0ff'
911 elif line.startswith('@@'):
912 bgcolor = 'e0e0e0'
913 elif line.startswith('+'):
914 bgcolor = 'e0ffe0'
915 elif line.startswith('-'):
916 bgcolor = 'ffe0e0'
917 elif line.startswith('commit '):
918 fgcolor = '808000'
919 elif line.startswith(' '):
920 fgcolor = '404040'
922 # Chop the trailing LF, we don't want it inside <pre>.
923 line = cgi.escape(line[:-1])
925 if bgcolor or fgcolor:
926 style = 'display:block; white-space:pre;'
927 if bgcolor:
928 style += 'background:#' + bgcolor + ';'
929 if fgcolor:
930 style += 'color:#' + fgcolor + ';'
931 # Use a <span style='display:block> to color the
932 # whole line. The newline must be inside the span
933 # to display properly both in Firefox and in
934 # text-based browser.
935 line = "<span style='%s'>%s\n</span>" % (style, line)
936 else:
937 line = line + '\n'
939 yield line
940 if self._contains_html_diff:
941 yield '</pre>'
943 for line in self._wrap_for_html(self.generate_email_footer()):
944 yield line
946 def get_alt_fromaddr(self):
947 return None
950 class Revision(Change):
951 """A Change consisting of a single git commit."""
953 CC_RE = re.compile(r'^\s*C[Cc]:\s*(?P<to>[^#]+@[^\s#]*)\s*(#.*)?$')
955 def __init__(self, reference_change, rev, num, tot):
956 Change.__init__(self, reference_change.environment)
957 self.reference_change = reference_change
958 self.rev = rev
959 self.change_type = self.reference_change.change_type
960 self.refname = self.reference_change.refname
961 self.num = num
962 self.tot = tot
963 self.author = read_git_output(['log', '--no-walk', '--format=%aN <%aE>', self.rev.sha1])
964 self.recipients = self.environment.get_revision_recipients(self)
966 self.cc_recipients = ''
967 if self.environment.get_scancommitforcc():
968 self.cc_recipients = ', '.join(to.strip() for to in self._cc_recipients())
969 if self.cc_recipients:
970 self.environment.log_msg(
971 'Add %s to CC for %s\n' % (self.cc_recipients, self.rev.sha1))
973 def _cc_recipients(self):
974 cc_recipients = []
975 message = read_git_output(['log', '--no-walk', '--format=%b', self.rev.sha1])
976 lines = message.strip().split('\n')
977 for line in lines:
978 m = re.match(self.CC_RE, line)
979 if m:
980 cc_recipients.append(m.group('to'))
982 return cc_recipients
984 def _compute_values(self):
985 values = Change._compute_values(self)
987 oneline = read_git_output(
988 ['log', '--format=%s', '--no-walk', self.rev.sha1]
991 values['rev'] = self.rev.sha1
992 values['rev_short'] = self.rev.short
993 values['change_type'] = self.change_type
994 values['refname'] = self.refname
995 values['short_refname'] = self.reference_change.short_refname
996 values['refname_type'] = self.reference_change.refname_type
997 values['reply_to_msgid'] = self.reference_change.msgid
998 values['num'] = self.num
999 values['tot'] = self.tot
1000 values['recipients'] = self.recipients
1001 if self.cc_recipients:
1002 values['cc_recipients'] = self.cc_recipients
1003 values['oneline'] = oneline
1004 values['author'] = self.author
1006 reply_to = self.environment.get_reply_to_commit(self)
1007 if reply_to:
1008 values['reply_to'] = reply_to
1010 return values
1012 def generate_email_header(self, **extra_values):
1013 for line in self.expand_header_lines(
1014 REVISION_HEADER_TEMPLATE, **extra_values
1016 yield line
1018 def generate_email_intro(self):
1019 for line in self.expand_lines(REVISION_INTRO_TEMPLATE):
1020 yield line
1022 def generate_email_body(self, push):
1023 """Show this revision."""
1025 for line in read_git_lines(
1026 ['log'] + self.environment.commitlogopts + ['-1', self.rev.sha1],
1027 keepends=True,
1029 if line.startswith('Date: ') and self.environment.date_substitute:
1030 yield self.environment.date_substitute + line[len('Date: '):]
1031 else:
1032 yield line
1034 def generate_email_footer(self):
1035 return self.expand_lines(REVISION_FOOTER_TEMPLATE)
1037 def generate_email(self, push, body_filter=None, extra_header_values={}):
1038 self._contains_diff()
1039 return Change.generate_email(self, push, body_filter, extra_header_values)
1041 def get_alt_fromaddr(self):
1042 return self.environment.from_commit
1045 class ReferenceChange(Change):
1046 """A Change to a Git reference.
1048 An abstract class representing a create, update, or delete of a
1049 Git reference. Derived classes handle specific types of reference
1050 (e.g., tags vs. branches). These classes generate the main
1051 reference change email summarizing the reference change and
1052 whether it caused any any commits to be added or removed.
1054 ReferenceChange objects are usually created using the static
1055 create() method, which has the logic to decide which derived class
1056 to instantiate."""
1058 REF_RE = re.compile(r'^refs\/(?P<area>[^\/]+)\/(?P<shortname>.*)$')
1060 @staticmethod
1061 def create(environment, oldrev, newrev, refname):
1062 """Return a ReferenceChange object representing the change.
1064 Return an object that represents the type of change that is being
1065 made. oldrev and newrev should be SHA1s or ZEROS."""
1067 old = GitObject(oldrev)
1068 new = GitObject(newrev)
1069 rev = new or old
1071 # The revision type tells us what type the commit is, combined with
1072 # the location of the ref we can decide between
1073 # - working branch
1074 # - tracking branch
1075 # - unannotated tag
1076 # - annotated tag
1077 m = ReferenceChange.REF_RE.match(refname)
1078 if m:
1079 area = m.group('area')
1080 short_refname = m.group('shortname')
1081 else:
1082 area = ''
1083 short_refname = refname
1085 if rev.type == 'tag':
1086 # Annotated tag:
1087 klass = AnnotatedTagChange
1088 elif rev.type == 'commit':
1089 if area == 'tags':
1090 # Non-annotated tag:
1091 klass = NonAnnotatedTagChange
1092 elif area == 'heads':
1093 # Branch:
1094 klass = BranchChange
1095 elif area == 'remotes':
1096 # Tracking branch:
1097 environment.log_warning(
1098 '*** Push-update of tracking branch %r\n'
1099 '*** - incomplete email generated.\n'
1100 % (refname,)
1102 klass = OtherReferenceChange
1103 else:
1104 # Some other reference namespace:
1105 environment.log_warning(
1106 '*** Push-update of strange reference %r\n'
1107 '*** - incomplete email generated.\n'
1108 % (refname,)
1110 klass = OtherReferenceChange
1111 else:
1112 # Anything else (is there anything else?)
1113 environment.log_warning(
1114 '*** Unknown type of update to %r (%s)\n'
1115 '*** - incomplete email generated.\n'
1116 % (refname, rev.type,)
1118 klass = OtherReferenceChange
1120 return klass(
1121 environment,
1122 refname=refname, short_refname=short_refname,
1123 old=old, new=new, rev=rev,
1126 def __init__(self, environment, refname, short_refname, old, new, rev):
1127 Change.__init__(self, environment)
1128 self.change_type = {
1129 (False, True): 'create',
1130 (True, True): 'update',
1131 (True, False): 'delete',
1132 }[bool(old), bool(new)]
1133 self.refname = refname
1134 self.short_refname = short_refname
1135 self.old = old
1136 self.new = new
1137 self.rev = rev
1138 self.msgid = make_msgid()
1139 self.diffopts = environment.diffopts
1140 self.graphopts = environment.graphopts
1141 self.logopts = environment.logopts
1142 self.commitlogopts = environment.commitlogopts
1143 self.showgraph = environment.refchange_showgraph
1144 self.showlog = environment.refchange_showlog
1146 self.header_template = REFCHANGE_HEADER_TEMPLATE
1147 self.intro_template = REFCHANGE_INTRO_TEMPLATE
1148 self.footer_template = FOOTER_TEMPLATE
1150 def _compute_values(self):
1151 values = Change._compute_values(self)
1153 values['change_type'] = self.change_type
1154 values['refname_type'] = self.refname_type
1155 values['refname'] = self.refname
1156 values['short_refname'] = self.short_refname
1157 values['msgid'] = self.msgid
1158 values['recipients'] = self.recipients
1159 values['oldrev'] = str(self.old)
1160 values['oldrev_short'] = self.old.short
1161 values['newrev'] = str(self.new)
1162 values['newrev_short'] = self.new.short
1164 if self.old:
1165 values['oldrev_type'] = self.old.type
1166 if self.new:
1167 values['newrev_type'] = self.new.type
1169 reply_to = self.environment.get_reply_to_refchange(self)
1170 if reply_to:
1171 values['reply_to'] = reply_to
1173 return values
1175 def send_single_combined_email(self, known_added_sha1s):
1176 """Determine if a combined refchange/revision email should be sent
1178 If there is only a single new (non-merge) commit added by a
1179 change, it is useful to combine the ReferenceChange and
1180 Revision emails into one. In such a case, return the single
1181 revision; otherwise, return None.
1183 This method is overridden in BranchChange."""
1185 return None
1187 def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):
1188 """Generate an email describing this change AND specified revision.
1190 Iterate over the lines (including the header lines) of an
1191 email describing this change. If body_filter is not None,
1192 then use it to filter the lines that are intended for the
1193 email body.
1195 The extra_header_values field is received as a dict and not as
1196 **kwargs, to allow passing other keyword arguments in the
1197 future (e.g. passing extra values to generate_email_intro()
1199 This method is overridden in BranchChange."""
1201 raise NotImplementedError
1203 def get_subject(self):
1204 template = {
1205 'create': REF_CREATED_SUBJECT_TEMPLATE,
1206 'update': REF_UPDATED_SUBJECT_TEMPLATE,
1207 'delete': REF_DELETED_SUBJECT_TEMPLATE,
1208 }[self.change_type]
1209 return self.expand(template)
1211 def generate_email_header(self, **extra_values):
1212 if 'subject' not in extra_values:
1213 extra_values['subject'] = self.get_subject()
1215 for line in self.expand_header_lines(
1216 self.header_template, **extra_values
1218 yield line
1220 def generate_email_intro(self):
1221 for line in self.expand_lines(self.intro_template):
1222 yield line
1224 def generate_email_body(self, push):
1225 """Call the appropriate body-generation routine.
1227 Call one of generate_create_summary() /
1228 generate_update_summary() / generate_delete_summary()."""
1230 change_summary = {
1231 'create': self.generate_create_summary,
1232 'delete': self.generate_delete_summary,
1233 'update': self.generate_update_summary,
1234 }[self.change_type](push)
1235 for line in change_summary:
1236 yield line
1238 for line in self.generate_revision_change_summary(push):
1239 yield line
1241 def generate_email_footer(self):
1242 return self.expand_lines(self.footer_template)
1244 def generate_revision_change_graph(self, push):
1245 if self.showgraph:
1246 args = ['--graph'] + self.graphopts
1247 for newold in ('new', 'old'):
1248 has_newold = False
1249 spec = push.get_commits_spec(newold, self)
1250 for line in git_log(spec, args=args, keepends=True):
1251 if not has_newold:
1252 has_newold = True
1253 yield '\n'
1254 yield 'Graph of %s commits:\n\n' % (
1255 {'new': 'new', 'old': 'discarded'}[newold],)
1256 yield ' ' + line
1257 if has_newold:
1258 yield '\n'
1260 def generate_revision_change_log(self, new_commits_list):
1261 if self.showlog:
1262 yield '\n'
1263 yield 'Detailed log of new commits:\n\n'
1264 for line in read_git_lines(
1265 ['log', '--no-walk'] +
1266 self.logopts +
1267 new_commits_list +
1268 ['--'],
1269 keepends=True,
1271 yield line
1273 def generate_new_revision_summary(self, tot, new_commits_list, push):
1274 for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot):
1275 yield line
1276 for line in self.generate_revision_change_graph(push):
1277 yield line
1278 for line in self.generate_revision_change_log(new_commits_list):
1279 yield line
1281 def generate_revision_change_summary(self, push):
1282 """Generate a summary of the revisions added/removed by this change."""
1284 if self.new.commit_sha1 and not self.old.commit_sha1:
1285 # A new reference was created. List the new revisions
1286 # brought by the new reference (i.e., those revisions that
1287 # were not in the repository before this reference
1288 # change).
1289 sha1s = list(push.get_new_commits(self))
1290 sha1s.reverse()
1291 tot = len(sha1s)
1292 new_revisions = [
1293 Revision(self, GitObject(sha1), num=i + 1, tot=tot)
1294 for (i, sha1) in enumerate(sha1s)
1297 if new_revisions:
1298 yield self.expand('This %(refname_type)s includes the following new commits:\n')
1299 yield '\n'
1300 for r in new_revisions:
1301 (sha1, subject) = r.rev.get_summary()
1302 yield r.expand(
1303 BRIEF_SUMMARY_TEMPLATE, action='new', text=subject,
1305 yield '\n'
1306 for line in self.generate_new_revision_summary(
1307 tot, [r.rev.sha1 for r in new_revisions], push):
1308 yield line
1309 else:
1310 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
1311 yield line
1313 elif self.new.commit_sha1 and self.old.commit_sha1:
1314 # A reference was changed to point at a different commit.
1315 # List the revisions that were removed and/or added *from
1316 # that reference* by this reference change, along with a
1317 # diff between the trees for its old and new values.
1319 # List of the revisions that were added to the branch by
1320 # this update. Note this list can include revisions that
1321 # have already had notification emails; we want such
1322 # revisions in the summary even though we will not send
1323 # new notification emails for them.
1324 adds = list(generate_summaries(
1325 '--topo-order', '--reverse', '%s..%s'
1326 % (self.old.commit_sha1, self.new.commit_sha1,)
1329 # List of the revisions that were removed from the branch
1330 # by this update. This will be empty except for
1331 # non-fast-forward updates.
1332 discards = list(generate_summaries(
1333 '%s..%s' % (self.new.commit_sha1, self.old.commit_sha1,)
1336 if adds:
1337 new_commits_list = push.get_new_commits(self)
1338 else:
1339 new_commits_list = []
1340 new_commits = CommitSet(new_commits_list)
1342 if discards:
1343 discarded_commits = CommitSet(push.get_discarded_commits(self))
1344 else:
1345 discarded_commits = CommitSet([])
1347 if discards and adds:
1348 for (sha1, subject) in discards:
1349 if sha1 in discarded_commits:
1350 action = 'discards'
1351 else:
1352 action = 'omits'
1353 yield self.expand(
1354 BRIEF_SUMMARY_TEMPLATE, action=action,
1355 rev_short=sha1, text=subject,
1357 for (sha1, subject) in adds:
1358 if sha1 in new_commits:
1359 action = 'new'
1360 else:
1361 action = 'adds'
1362 yield self.expand(
1363 BRIEF_SUMMARY_TEMPLATE, action=action,
1364 rev_short=sha1, text=subject,
1366 yield '\n'
1367 for line in self.expand_lines(NON_FF_TEMPLATE):
1368 yield line
1370 elif discards:
1371 for (sha1, subject) in discards:
1372 if sha1 in discarded_commits:
1373 action = 'discards'
1374 else:
1375 action = 'omits'
1376 yield self.expand(
1377 BRIEF_SUMMARY_TEMPLATE, action=action,
1378 rev_short=sha1, text=subject,
1380 yield '\n'
1381 for line in self.expand_lines(REWIND_ONLY_TEMPLATE):
1382 yield line
1384 elif adds:
1385 (sha1, subject) = self.old.get_summary()
1386 yield self.expand(
1387 BRIEF_SUMMARY_TEMPLATE, action='from',
1388 rev_short=sha1, text=subject,
1390 for (sha1, subject) in adds:
1391 if sha1 in new_commits:
1392 action = 'new'
1393 else:
1394 action = 'adds'
1395 yield self.expand(
1396 BRIEF_SUMMARY_TEMPLATE, action=action,
1397 rev_short=sha1, text=subject,
1400 yield '\n'
1402 if new_commits:
1403 for line in self.generate_new_revision_summary(
1404 len(new_commits), new_commits_list, push):
1405 yield line
1406 else:
1407 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
1408 yield line
1409 for line in self.generate_revision_change_graph(push):
1410 yield line
1412 # The diffstat is shown from the old revision to the new
1413 # revision. This is to show the truth of what happened in
1414 # this change. There's no point showing the stat from the
1415 # base to the new revision because the base is effectively a
1416 # random revision at this point - the user will be interested
1417 # in what this revision changed - including the undoing of
1418 # previous revisions in the case of non-fast-forward updates.
1419 yield '\n'
1420 yield 'Summary of changes:\n'
1421 for line in read_git_lines(
1422 ['diff-tree'] +
1423 self.diffopts +
1424 ['%s..%s' % (self.old.commit_sha1, self.new.commit_sha1,)],
1425 keepends=True,
1427 yield line
1429 elif self.old.commit_sha1 and not self.new.commit_sha1:
1430 # A reference was deleted. List the revisions that were
1431 # removed from the repository by this reference change.
1433 sha1s = list(push.get_discarded_commits(self))
1434 tot = len(sha1s)
1435 discarded_revisions = [
1436 Revision(self, GitObject(sha1), num=i + 1, tot=tot)
1437 for (i, sha1) in enumerate(sha1s)
1440 if discarded_revisions:
1441 for line in self.expand_lines(DISCARDED_REVISIONS_TEMPLATE):
1442 yield line
1443 yield '\n'
1444 for r in discarded_revisions:
1445 (sha1, subject) = r.rev.get_summary()
1446 yield r.expand(
1447 BRIEF_SUMMARY_TEMPLATE, action='discards', text=subject,
1449 for line in self.generate_revision_change_graph(push):
1450 yield line
1451 else:
1452 for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE):
1453 yield line
1455 elif not self.old.commit_sha1 and not self.new.commit_sha1:
1456 for line in self.expand_lines(NON_COMMIT_UPDATE_TEMPLATE):
1457 yield line
1459 def generate_create_summary(self, push):
1460 """Called for the creation of a reference."""
1462 # This is a new reference and so oldrev is not valid
1463 (sha1, subject) = self.new.get_summary()
1464 yield self.expand(
1465 BRIEF_SUMMARY_TEMPLATE, action='at',
1466 rev_short=sha1, text=subject,
1468 yield '\n'
1470 def generate_update_summary(self, push):
1471 """Called for the change of a pre-existing branch."""
1473 return iter([])
1475 def generate_delete_summary(self, push):
1476 """Called for the deletion of any type of reference."""
1478 (sha1, subject) = self.old.get_summary()
1479 yield self.expand(
1480 BRIEF_SUMMARY_TEMPLATE, action='was',
1481 rev_short=sha1, text=subject,
1483 yield '\n'
1485 def get_alt_fromaddr(self):
1486 return self.environment.from_refchange
1489 class BranchChange(ReferenceChange):
1490 refname_type = 'branch'
1492 def __init__(self, environment, refname, short_refname, old, new, rev):
1493 ReferenceChange.__init__(
1494 self, environment,
1495 refname=refname, short_refname=short_refname,
1496 old=old, new=new, rev=rev,
1498 self.recipients = environment.get_refchange_recipients(self)
1499 self._single_revision = None
1501 def send_single_combined_email(self, known_added_sha1s):
1502 if not self.environment.combine_when_single_commit:
1503 return None
1505 # In the sadly-all-too-frequent usecase of people pushing only
1506 # one of their commits at a time to a repository, users feel
1507 # the reference change summary emails are noise rather than
1508 # important signal. This is because, in this particular
1509 # usecase, there is a reference change summary email for each
1510 # new commit, and all these summaries do is point out that
1511 # there is one new commit (which can readily be inferred by
1512 # the existence of the individual revision email that is also
1513 # sent). In such cases, our users prefer there to be a combined
1514 # reference change summary/new revision email.
1516 # So, if the change is an update and it doesn't discard any
1517 # commits, and it adds exactly one non-merge commit (gerrit
1518 # forces a workflow where every commit is individually merged
1519 # and the git-multimail hook fired off for just this one
1520 # change), then we send a combined refchange/revision email.
1521 try:
1522 # If this change is a reference update that doesn't discard
1523 # any commits...
1524 if self.change_type != 'update':
1525 return None
1527 if read_git_lines(
1528 ['merge-base', self.old.sha1, self.new.sha1]
1529 ) != [self.old.sha1]:
1530 return None
1532 # Check if this update introduced exactly one non-merge
1533 # commit:
1535 def split_line(line):
1536 """Split line into (sha1, [parent,...])."""
1538 words = line.split()
1539 return (words[0], words[1:])
1541 # Get the new commits introduced by the push as a list of
1542 # (sha1, [parent,...])
1543 new_commits = [
1544 split_line(line)
1545 for line in read_git_lines(
1547 'log', '-3', '--format=%H %P',
1548 '%s..%s' % (self.old.sha1, self.new.sha1),
1553 if not new_commits:
1554 return None
1556 # If the newest commit is a merge, save it for a later check
1557 # but otherwise ignore it
1558 merge = None
1559 tot = len(new_commits)
1560 if len(new_commits[0][1]) > 1:
1561 merge = new_commits[0][0]
1562 del new_commits[0]
1564 # Our primary check: we can't combine if more than one commit
1565 # is introduced. We also currently only combine if the new
1566 # commit is a non-merge commit, though it may make sense to
1567 # combine if it is a merge as well.
1568 if not (
1569 len(new_commits) == 1 and
1570 len(new_commits[0][1]) == 1 and
1571 new_commits[0][0] in known_added_sha1s
1573 return None
1575 # We do not want to combine revision and refchange emails if
1576 # those go to separate locations.
1577 rev = Revision(self, GitObject(new_commits[0][0]), 1, tot)
1578 if rev.recipients != self.recipients:
1579 return None
1581 # We ignored the newest commit if it was just a merge of the one
1582 # commit being introduced. But we don't want to ignore that
1583 # merge commit it it involved conflict resolutions. Check that.
1584 if merge and merge != read_git_output(['diff-tree', '--cc', merge]):
1585 return None
1587 # We can combine the refchange and one new revision emails
1588 # into one. Return the Revision that a combined email should
1589 # be sent about.
1590 return rev
1591 except CommandError:
1592 # Cannot determine number of commits in old..new or new..old;
1593 # don't combine reference/revision emails:
1594 return None
1596 def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):
1597 values = revision.get_values()
1598 if extra_header_values:
1599 values.update(extra_header_values)
1600 if 'subject' not in extra_header_values:
1601 values['subject'] = self.expand(COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE, **values)
1603 self._single_revision = revision
1604 self._contains_diff()
1605 self.header_template = COMBINED_HEADER_TEMPLATE
1606 self.intro_template = COMBINED_INTRO_TEMPLATE
1607 self.footer_template = COMBINED_FOOTER_TEMPLATE
1608 for line in self.generate_email(push, body_filter, values):
1609 yield line
1611 def generate_email_body(self, push):
1612 '''Call the appropriate body generation routine.
1614 If this is a combined refchange/revision email, the special logic
1615 for handling this combined email comes from this function. For
1616 other cases, we just use the normal handling.'''
1618 # If self._single_revision isn't set; don't override
1619 if not self._single_revision:
1620 for line in super(BranchChange, self).generate_email_body(push):
1621 yield line
1622 return
1624 # This is a combined refchange/revision email; we first provide
1625 # some info from the refchange portion, and then call the revision
1626 # generate_email_body function to handle the revision portion.
1627 adds = list(generate_summaries(
1628 '--topo-order', '--reverse', '%s..%s'
1629 % (self.old.commit_sha1, self.new.commit_sha1,)
1632 yield self.expand("The following commit(s) were added to %(refname)s by this push:\n")
1633 for (sha1, subject) in adds:
1634 yield self.expand(
1635 BRIEF_SUMMARY_TEMPLATE, action='new',
1636 rev_short=sha1, text=subject,
1639 yield self._single_revision.rev.short + " is described below\n"
1640 yield '\n'
1642 for line in self._single_revision.generate_email_body(push):
1643 yield line
1646 class AnnotatedTagChange(ReferenceChange):
1647 refname_type = 'annotated tag'
1649 def __init__(self, environment, refname, short_refname, old, new, rev):
1650 ReferenceChange.__init__(
1651 self, environment,
1652 refname=refname, short_refname=short_refname,
1653 old=old, new=new, rev=rev,
1655 self.recipients = environment.get_announce_recipients(self)
1656 self.show_shortlog = environment.announce_show_shortlog
1658 ANNOTATED_TAG_FORMAT = (
1659 '%(*objectname)\n'
1660 '%(*objecttype)\n'
1661 '%(taggername)\n'
1662 '%(taggerdate)'
1665 def describe_tag(self, push):
1666 """Describe the new value of an annotated tag."""
1668 # Use git for-each-ref to pull out the individual fields from
1669 # the tag
1670 [tagobject, tagtype, tagger, tagged] = read_git_lines(
1671 ['for-each-ref', '--format=%s' % (self.ANNOTATED_TAG_FORMAT,), self.refname],
1674 yield self.expand(
1675 BRIEF_SUMMARY_TEMPLATE, action='tagging',
1676 rev_short=tagobject, text='(%s)' % (tagtype,),
1678 if tagtype == 'commit':
1679 # If the tagged object is a commit, then we assume this is a
1680 # release, and so we calculate which tag this tag is
1681 # replacing
1682 try:
1683 prevtag = read_git_output(['describe', '--abbrev=0', '%s^' % (self.new,)])
1684 except CommandError:
1685 prevtag = None
1686 if prevtag:
1687 yield ' replaces %s\n' % (prevtag,)
1688 else:
1689 prevtag = None
1690 yield ' length %s bytes\n' % (read_git_output(['cat-file', '-s', tagobject]),)
1692 yield ' tagged by %s\n' % (tagger,)
1693 yield ' on %s\n' % (tagged,)
1694 yield '\n'
1696 # Show the content of the tag message; this might contain a
1697 # change log or release notes so is worth displaying.
1698 yield LOGBEGIN
1699 contents = list(read_git_lines(['cat-file', 'tag', self.new.sha1], keepends=True))
1700 contents = contents[contents.index('\n') + 1:]
1701 if contents and contents[-1][-1:] != '\n':
1702 contents.append('\n')
1703 for line in contents:
1704 yield line
1706 if self.show_shortlog and tagtype == 'commit':
1707 # Only commit tags make sense to have rev-list operations
1708 # performed on them
1709 yield '\n'
1710 if prevtag:
1711 # Show changes since the previous release
1712 revlist = read_git_output(
1713 ['rev-list', '--pretty=short', '%s..%s' % (prevtag, self.new,)],
1714 keepends=True,
1716 else:
1717 # No previous tag, show all the changes since time
1718 # began
1719 revlist = read_git_output(
1720 ['rev-list', '--pretty=short', '%s' % (self.new,)],
1721 keepends=True,
1723 for line in read_git_lines(['shortlog'], input=revlist, keepends=True):
1724 yield line
1726 yield LOGEND
1727 yield '\n'
1729 def generate_create_summary(self, push):
1730 """Called for the creation of an annotated tag."""
1732 for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1733 yield line
1735 for line in self.describe_tag(push):
1736 yield line
1738 def generate_update_summary(self, push):
1739 """Called for the update of an annotated tag.
1741 This is probably a rare event and may not even be allowed."""
1743 for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1744 yield line
1746 for line in self.describe_tag(push):
1747 yield line
1749 def generate_delete_summary(self, push):
1750 """Called when a non-annotated reference is updated."""
1752 for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1753 yield line
1755 yield self.expand(' tag was %(oldrev_short)s\n')
1756 yield '\n'
1759 class NonAnnotatedTagChange(ReferenceChange):
1760 refname_type = 'tag'
1762 def __init__(self, environment, refname, short_refname, old, new, rev):
1763 ReferenceChange.__init__(
1764 self, environment,
1765 refname=refname, short_refname=short_refname,
1766 old=old, new=new, rev=rev,
1768 self.recipients = environment.get_refchange_recipients(self)
1770 def generate_create_summary(self, push):
1771 """Called for the creation of an annotated tag."""
1773 for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1774 yield line
1776 def generate_update_summary(self, push):
1777 """Called when a non-annotated reference is updated."""
1779 for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1780 yield line
1782 def generate_delete_summary(self, push):
1783 """Called when a non-annotated reference is updated."""
1785 for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1786 yield line
1788 for line in ReferenceChange.generate_delete_summary(self, push):
1789 yield line
1792 class OtherReferenceChange(ReferenceChange):
1793 refname_type = 'reference'
1795 def __init__(self, environment, refname, short_refname, old, new, rev):
1796 # We use the full refname as short_refname, because otherwise
1797 # the full name of the reference would not be obvious from the
1798 # text of the email.
1799 ReferenceChange.__init__(
1800 self, environment,
1801 refname=refname, short_refname=refname,
1802 old=old, new=new, rev=rev,
1804 self.recipients = environment.get_refchange_recipients(self)
1807 class Mailer(object):
1808 """An object that can send emails."""
1810 def send(self, lines, to_addrs):
1811 """Send an email consisting of lines.
1813 lines must be an iterable over the lines constituting the
1814 header and body of the email. to_addrs is a list of recipient
1815 addresses (can be needed even if lines already contains a
1816 "To:" field). It can be either a string (comma-separated list
1817 of email addresses) or a Python list of individual email
1818 addresses.
1822 raise NotImplementedError()
1825 class SendMailer(Mailer):
1826 """Send emails using 'sendmail -oi -t'."""
1828 SENDMAIL_CANDIDATES = [
1829 '/usr/sbin/sendmail',
1830 '/usr/lib/sendmail',
1833 @staticmethod
1834 def find_sendmail():
1835 for path in SendMailer.SENDMAIL_CANDIDATES:
1836 if os.access(path, os.X_OK):
1837 return path
1838 else:
1839 raise ConfigurationException(
1840 'No sendmail executable found. '
1841 'Try setting multimailhook.sendmailCommand.'
1844 def __init__(self, command=None, envelopesender=None):
1845 """Construct a SendMailer instance.
1847 command should be the command and arguments used to invoke
1848 sendmail, as a list of strings. If an envelopesender is
1849 provided, it will also be passed to the command, via '-f
1850 envelopesender'."""
1852 if command:
1853 self.command = command[:]
1854 else:
1855 self.command = [self.find_sendmail(), '-oi', '-t']
1857 if envelopesender:
1858 self.command.extend(['-f', envelopesender])
1860 def send(self, lines, to_addrs):
1861 try:
1862 p = subprocess.Popen(self.command, stdin=subprocess.PIPE)
1863 except OSError:
1864 sys.stderr.write(
1865 '*** Cannot execute command: %s\n' % ' '.join(self.command) +
1866 '*** %s\n' % sys.exc_info()[1] +
1867 '*** Try setting multimailhook.mailer to "smtp"\n' +
1868 '*** to send emails without using the sendmail command.\n'
1870 sys.exit(1)
1871 try:
1872 lines = (str_to_bytes(line) for line in lines)
1873 p.stdin.writelines(lines)
1874 except Exception:
1875 sys.stderr.write(
1876 '*** Error while generating commit email\n'
1877 '*** - mail sending aborted.\n'
1879 try:
1880 # subprocess.terminate() is not available in Python 2.4
1881 p.terminate()
1882 except AttributeError:
1883 pass
1884 raise
1885 else:
1886 p.stdin.close()
1887 retcode = p.wait()
1888 if retcode:
1889 raise CommandError(self.command, retcode)
1892 class SMTPMailer(Mailer):
1893 """Send emails using Python's smtplib."""
1895 def __init__(self, envelopesender, smtpserver,
1896 smtpservertimeout=10.0, smtpserverdebuglevel=0,
1897 smtpencryption='none',
1898 smtpuser='', smtppass='',
1900 if not envelopesender:
1901 sys.stderr.write(
1902 'fatal: git_multimail: cannot use SMTPMailer without a sender address.\n'
1903 'please set either multimailhook.envelopeSender or user.email\n'
1905 sys.exit(1)
1906 if smtpencryption == 'ssl' and not (smtpuser and smtppass):
1907 raise ConfigurationException(
1908 'Cannot use SMTPMailer with security option ssl '
1909 'without options username and password.'
1911 self.envelopesender = envelopesender
1912 self.smtpserver = smtpserver
1913 self.smtpservertimeout = smtpservertimeout
1914 self.smtpserverdebuglevel = smtpserverdebuglevel
1915 self.security = smtpencryption
1916 self.username = smtpuser
1917 self.password = smtppass
1918 try:
1919 def call(klass, server, timeout):
1920 try:
1921 return klass(server, timeout=timeout)
1922 except TypeError:
1923 # Old Python versions do not have timeout= argument.
1924 return klass(server)
1925 if self.security == 'none':
1926 self.smtp = call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)
1927 elif self.security == 'ssl':
1928 self.smtp = call(smtplib.SMTP_SSL, self.smtpserver, timeout=self.smtpservertimeout)
1929 elif self.security == 'tls':
1930 if ':' not in self.smtpserver:
1931 self.smtpserver += ':587' # default port for TLS
1932 self.smtp = call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)
1933 self.smtp.ehlo()
1934 self.smtp.starttls()
1935 self.smtp.ehlo()
1936 else:
1937 sys.stdout.write('*** Error: Control reached an invalid option. ***')
1938 sys.exit(1)
1939 if self.smtpserverdebuglevel > 0:
1940 sys.stdout.write(
1941 "*** Setting debug on for SMTP server connection (%s) ***\n"
1942 % self.smtpserverdebuglevel)
1943 self.smtp.set_debuglevel(self.smtpserverdebuglevel)
1944 except Exception:
1945 sys.stderr.write(
1946 '*** Error establishing SMTP connection to %s ***\n'
1947 % self.smtpserver)
1948 sys.stderr.write('*** %s\n' % sys.exc_info()[1])
1949 sys.exit(1)
1951 def __del__(self):
1952 if hasattr(self, 'smtp'):
1953 self.smtp.quit()
1955 def send(self, lines, to_addrs):
1956 try:
1957 if self.username or self.password:
1958 self.smtp.login(self.username, self.password)
1959 msg = ''.join(lines)
1960 # turn comma-separated list into Python list if needed.
1961 if isinstance(to_addrs, basestring):
1962 to_addrs = [email for (name, email) in getaddresses([to_addrs])]
1963 self.smtp.sendmail(self.envelopesender, to_addrs, msg)
1964 except Exception:
1965 sys.stderr.write('*** Error sending email ***\n')
1966 sys.stderr.write('*** %s\n' % sys.exc_info()[1])
1967 self.smtp.quit()
1968 sys.exit(1)
1971 class OutputMailer(Mailer):
1972 """Write emails to an output stream, bracketed by lines of '=' characters.
1974 This is intended for debugging purposes."""
1976 SEPARATOR = '=' * 75 + '\n'
1978 def __init__(self, f):
1979 self.f = f
1981 def send(self, lines, to_addrs):
1982 write_str(self.f, self.SEPARATOR)
1983 for line in lines:
1984 write_str(self.f, line)
1985 write_str(self.f, self.SEPARATOR)
1988 def get_git_dir():
1989 """Determine GIT_DIR.
1991 Determine GIT_DIR either from the GIT_DIR environment variable or
1992 from the working directory, using Git's usual rules."""
1994 try:
1995 return read_git_output(['rev-parse', '--git-dir'])
1996 except CommandError:
1997 sys.stderr.write('fatal: git_multimail: not in a git directory\n')
1998 sys.exit(1)
2001 class Environment(object):
2002 """Describes the environment in which the push is occurring.
2004 An Environment object encapsulates information about the local
2005 environment. For example, it knows how to determine:
2007 * the name of the repository to which the push occurred
2009 * what user did the push
2011 * what users want to be informed about various types of changes.
2013 An Environment object is expected to have the following methods:
2015 get_repo_shortname()
2017 Return a short name for the repository, for display
2018 purposes.
2020 get_repo_path()
2022 Return the absolute path to the Git repository.
2024 get_emailprefix()
2026 Return a string that will be prefixed to every email's
2027 subject.
2029 get_pusher()
2031 Return the username of the person who pushed the changes.
2032 This value is used in the email body to indicate who
2033 pushed the change.
2035 get_pusher_email() (may return None)
2037 Return the email address of the person who pushed the
2038 changes. The value should be a single RFC 2822 email
2039 address as a string; e.g., "Joe User <user@example.com>"
2040 if available, otherwise "user@example.com". If set, the
2041 value is used as the Reply-To address for refchange
2042 emails. If it is impossible to determine the pusher's
2043 email, this attribute should be set to None (in which case
2044 no Reply-To header will be output).
2046 get_sender()
2048 Return the address to be used as the 'From' email address
2049 in the email envelope.
2051 get_fromaddr(change=None)
2053 Return the 'From' email address used in the email 'From:'
2054 headers. If the change is known when this function is
2055 called, it is passed in as the 'change' parameter. (May
2056 be a full RFC 2822 email address like 'Joe User
2057 <user@example.com>'.)
2059 get_administrator()
2061 Return the name and/or email of the repository
2062 administrator. This value is used in the footer as the
2063 person to whom requests to be removed from the
2064 notification list should be sent. Ideally, it should
2065 include a valid email address.
2067 get_reply_to_refchange()
2068 get_reply_to_commit()
2070 Return the address to use in the email "Reply-To" header,
2071 as a string. These can be an RFC 2822 email address, or
2072 None to omit the "Reply-To" header.
2073 get_reply_to_refchange() is used for refchange emails;
2074 get_reply_to_commit() is used for individual commit
2075 emails.
2077 get_ref_filter_regex()
2079 Return a tuple -- a compiled regex, and a boolean indicating
2080 whether the regex picks refs to include (if False, the regex
2081 matches on refs to exclude).
2083 get_default_ref_ignore_regex()
2085 Return a regex that should be ignored for both what emails
2086 to send and when computing what commits are considered new
2087 to the repository. Default is "^refs/notes/".
2089 They should also define the following attributes:
2091 announce_show_shortlog (bool)
2093 True iff announce emails should include a shortlog.
2095 commit_email_format (string)
2097 If "html", generate commit emails in HTML instead of plain text
2098 used by default.
2100 refchange_showgraph (bool)
2102 True iff refchanges emails should include a detailed graph.
2104 refchange_showlog (bool)
2106 True iff refchanges emails should include a detailed log.
2108 diffopts (list of strings)
2110 The options that should be passed to 'git diff' for the
2111 summary email. The value should be a list of strings
2112 representing words to be passed to the command.
2114 graphopts (list of strings)
2116 Analogous to diffopts, but contains options passed to
2117 'git log --graph' when generating the detailed graph for
2118 a set of commits (see refchange_showgraph)
2120 logopts (list of strings)
2122 Analogous to diffopts, but contains options passed to
2123 'git log' when generating the detailed log for a set of
2124 commits (see refchange_showlog)
2126 commitlogopts (list of strings)
2128 The options that should be passed to 'git log' for each
2129 commit mail. The value should be a list of strings
2130 representing words to be passed to the command.
2132 date_substitute (string)
2134 String to be used in substitution for 'Date:' at start of
2135 line in the output of 'git log'.
2137 quiet (bool)
2138 On success do not write to stderr
2140 stdout (bool)
2141 Write email to stdout rather than emailing. Useful for debugging
2143 combine_when_single_commit (bool)
2145 True if a combined email should be produced when a single
2146 new commit is pushed to a branch, False otherwise.
2148 from_refchange, from_commit (strings)
2150 Addresses to use for the From: field for refchange emails
2151 and commit emails respectively. Set from
2152 multimailhook.fromRefchange and multimailhook.fromCommit
2153 by ConfigEnvironmentMixin.
2157 REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$')
2159 def __init__(self, osenv=None):
2160 self.osenv = osenv or os.environ
2161 self.announce_show_shortlog = False
2162 self.commit_email_format = "text"
2163 self.maxcommitemails = 500
2164 self.diffopts = ['--stat', '--summary', '--find-copies-harder']
2165 self.graphopts = ['--oneline', '--decorate']
2166 self.logopts = []
2167 self.refchange_showgraph = False
2168 self.refchange_showlog = False
2169 self.commitlogopts = ['-C', '--stat', '-p', '--cc']
2170 self.date_substitute = 'AuthorDate: '
2171 self.quiet = False
2172 self.stdout = False
2173 self.combine_when_single_commit = True
2175 self.COMPUTED_KEYS = [
2176 'administrator',
2177 'charset',
2178 'emailprefix',
2179 'pusher',
2180 'pusher_email',
2181 'repo_path',
2182 'repo_shortname',
2183 'sender',
2186 self._values = None
2188 def get_repo_shortname(self):
2189 """Use the last part of the repo path, with ".git" stripped off if present."""
2191 basename = os.path.basename(os.path.abspath(self.get_repo_path()))
2192 m = self.REPO_NAME_RE.match(basename)
2193 if m:
2194 return m.group('name')
2195 else:
2196 return basename
2198 def get_pusher(self):
2199 raise NotImplementedError()
2201 def get_pusher_email(self):
2202 return None
2204 def get_fromaddr(self, change=None):
2205 config = Config('user')
2206 fromname = config.get('name', default='')
2207 fromemail = config.get('email', default='')
2208 if fromemail:
2209 return formataddr([fromname, fromemail])
2210 return self.get_sender()
2212 def get_administrator(self):
2213 return 'the administrator of this repository'
2215 def get_emailprefix(self):
2216 return ''
2218 def get_repo_path(self):
2219 if read_git_output(['rev-parse', '--is-bare-repository']) == 'true':
2220 path = get_git_dir()
2221 else:
2222 path = read_git_output(['rev-parse', '--show-toplevel'])
2223 return os.path.abspath(path)
2225 def get_charset(self):
2226 return CHARSET
2228 def get_values(self):
2229 """Return a dictionary {keyword: expansion} for this Environment.
2231 This method is called by Change._compute_values(). The keys
2232 in the returned dictionary are available to be used in any of
2233 the templates. The dictionary is created by calling
2234 self.get_NAME() for each of the attributes named in
2235 COMPUTED_KEYS and recording those that do not return None.
2236 The return value is always a new dictionary."""
2238 if self._values is None:
2239 values = {}
2241 for key in self.COMPUTED_KEYS:
2242 value = getattr(self, 'get_%s' % (key,))()
2243 if value is not None:
2244 values[key] = value
2246 self._values = values
2248 return self._values.copy()
2250 def get_refchange_recipients(self, refchange):
2251 """Return the recipients for notifications about refchange.
2253 Return the list of email addresses to which notifications
2254 about the specified ReferenceChange should be sent."""
2256 raise NotImplementedError()
2258 def get_announce_recipients(self, annotated_tag_change):
2259 """Return the recipients for notifications about annotated_tag_change.
2261 Return the list of email addresses to which notifications
2262 about the specified AnnotatedTagChange should be sent."""
2264 raise NotImplementedError()
2266 def get_reply_to_refchange(self, refchange):
2267 return self.get_pusher_email()
2269 def get_revision_recipients(self, revision):
2270 """Return the recipients for messages about revision.
2272 Return the list of email addresses to which notifications
2273 about the specified Revision should be sent. This method
2274 could be overridden, for example, to take into account the
2275 contents of the revision when deciding whom to notify about
2276 it. For example, there could be a scheme for users to express
2277 interest in particular files or subdirectories, and only
2278 receive notification emails for revisions that affecting those
2279 files."""
2281 raise NotImplementedError()
2283 def get_reply_to_commit(self, revision):
2284 return revision.author
2286 def get_default_ref_ignore_regex(self):
2287 # The commit messages of git notes are essentially meaningless
2288 # and "filenames" in git notes commits are an implementational
2289 # detail that might surprise users at first. As such, we
2290 # would need a completely different method for handling emails
2291 # of git notes in order for them to be of benefit for users,
2292 # which we simply do not have right now.
2293 return "^refs/notes/"
2295 def filter_body(self, lines):
2296 """Filter the lines intended for an email body.
2298 lines is an iterable over the lines that would go into the
2299 email body. Filter it (e.g., limit the number of lines, the
2300 line length, character set, etc.), returning another iterable.
2301 See FilterLinesEnvironmentMixin and MaxlinesEnvironmentMixin
2302 for classes implementing this functionality."""
2304 return lines
2306 def log_msg(self, msg):
2307 """Write the string msg on a log file or on stderr.
2309 Sends the text to stderr by default, override to change the behavior."""
2310 write_str(sys.stderr, msg)
2312 def log_warning(self, msg):
2313 """Write the string msg on a log file or on stderr.
2315 Sends the text to stderr by default, override to change the behavior."""
2316 write_str(sys.stderr, msg)
2318 def log_error(self, msg):
2319 """Write the string msg on a log file or on stderr.
2321 Sends the text to stderr by default, override to change the behavior."""
2322 write_str(sys.stderr, msg)
2325 class ConfigEnvironmentMixin(Environment):
2326 """A mixin that sets self.config to its constructor's config argument.
2328 This class's constructor consumes the "config" argument.
2330 Mixins that need to inspect the config should inherit from this
2331 class (1) to make sure that "config" is still in the constructor
2332 arguments with its own constructor runs and/or (2) to be sure that
2333 self.config is set after construction."""
2335 def __init__(self, config, **kw):
2336 super(ConfigEnvironmentMixin, self).__init__(**kw)
2337 self.config = config
2340 class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):
2341 """An Environment that reads most of its information from "git config"."""
2343 @staticmethod
2344 def forbid_field_values(name, value, forbidden):
2345 for forbidden_val in forbidden:
2346 if value is not None and value.lower() == forbidden:
2347 raise ConfigurationException(
2348 '"%s" is not an allowed setting for %s' % (value, name)
2351 def __init__(self, config, **kw):
2352 super(ConfigOptionsEnvironmentMixin, self).__init__(
2353 config=config, **kw
2356 for var, cfg in (
2357 ('announce_show_shortlog', 'announceshortlog'),
2358 ('refchange_showgraph', 'refchangeShowGraph'),
2359 ('refchange_showlog', 'refchangeshowlog'),
2360 ('quiet', 'quiet'),
2361 ('stdout', 'stdout'),
2363 val = config.get_bool(cfg)
2364 if val is not None:
2365 setattr(self, var, val)
2367 commit_email_format = config.get('commitEmailFormat')
2368 if commit_email_format is not None:
2369 if commit_email_format != "html" and commit_email_format != "text":
2370 self.log_warning(
2371 '*** Unknown value for multimailhook.commitEmailFormat: %s\n' %
2372 commit_email_format +
2373 '*** Expected either "text" or "html". Ignoring.\n'
2375 else:
2376 self.commit_email_format = commit_email_format
2378 maxcommitemails = config.get('maxcommitemails')
2379 if maxcommitemails is not None:
2380 try:
2381 self.maxcommitemails = int(maxcommitemails)
2382 except ValueError:
2383 self.log_warning(
2384 '*** Malformed value for multimailhook.maxCommitEmails: %s\n'
2385 % maxcommitemails +
2386 '*** Expected a number. Ignoring.\n'
2389 diffopts = config.get('diffopts')
2390 if diffopts is not None:
2391 self.diffopts = shlex.split(diffopts)
2393 graphopts = config.get('graphOpts')
2394 if graphopts is not None:
2395 self.graphopts = shlex.split(graphopts)
2397 logopts = config.get('logopts')
2398 if logopts is not None:
2399 self.logopts = shlex.split(logopts)
2401 commitlogopts = config.get('commitlogopts')
2402 if commitlogopts is not None:
2403 self.commitlogopts = shlex.split(commitlogopts)
2405 date_substitute = config.get('dateSubstitute')
2406 if date_substitute == 'none':
2407 self.date_substitute = None
2408 elif date_substitute is not None:
2409 self.date_substitute = date_substitute
2411 reply_to = config.get('replyTo')
2412 self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to)
2413 self.forbid_field_values('replyToRefchange',
2414 self.__reply_to_refchange,
2415 ['author'])
2416 self.__reply_to_commit = config.get('replyToCommit', default=reply_to)
2418 from_addr = self.config.get('from')
2419 self.from_refchange = config.get('fromRefchange')
2420 self.forbid_field_values('fromRefchange',
2421 self.from_refchange,
2422 ['author', 'none'])
2423 self.from_commit = config.get('fromCommit')
2424 self.forbid_field_values('fromCommit',
2425 self.from_commit,
2426 ['none'])
2428 combine = config.get_bool('combineWhenSingleCommit')
2429 if combine is not None:
2430 self.combine_when_single_commit = combine
2432 def get_administrator(self):
2433 return (
2434 self.config.get('administrator') or
2435 self.get_sender() or
2436 super(ConfigOptionsEnvironmentMixin, self).get_administrator()
2439 def get_repo_shortname(self):
2440 return (
2441 self.config.get('reponame') or
2442 super(ConfigOptionsEnvironmentMixin, self).get_repo_shortname()
2445 def get_emailprefix(self):
2446 emailprefix = self.config.get('emailprefix')
2447 if emailprefix is not None:
2448 emailprefix = emailprefix.strip()
2449 if emailprefix:
2450 return emailprefix + ' '
2451 else:
2452 return ''
2453 else:
2454 return '[%s] ' % (self.get_repo_shortname(),)
2456 def get_sender(self):
2457 return self.config.get('envelopesender')
2459 def process_addr(self, addr, change):
2460 if addr.lower() == 'author':
2461 if hasattr(change, 'author'):
2462 return change.author
2463 else:
2464 return None
2465 elif addr.lower() == 'pusher':
2466 return self.get_pusher_email()
2467 elif addr.lower() == 'none':
2468 return None
2469 else:
2470 return addr
2472 def get_fromaddr(self, change=None):
2473 fromaddr = self.config.get('from')
2474 if change:
2475 alt_fromaddr = change.get_alt_fromaddr()
2476 if alt_fromaddr:
2477 fromaddr = alt_fromaddr
2478 if fromaddr:
2479 fromaddr = self.process_addr(fromaddr, change)
2480 if fromaddr:
2481 return fromaddr
2482 return super(ConfigOptionsEnvironmentMixin, self).get_fromaddr(change)
2484 def get_reply_to_refchange(self, refchange):
2485 if self.__reply_to_refchange is None:
2486 return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_refchange(refchange)
2487 else:
2488 return self.process_addr(self.__reply_to_refchange, refchange)
2490 def get_reply_to_commit(self, revision):
2491 if self.__reply_to_commit is None:
2492 return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_commit(revision)
2493 else:
2494 return self.process_addr(self.__reply_to_commit, revision)
2496 def get_scancommitforcc(self):
2497 return self.config.get('scancommitforcc')
2500 class FilterLinesEnvironmentMixin(Environment):
2501 """Handle encoding and maximum line length of body lines.
2503 emailmaxlinelength (int or None)
2505 The maximum length of any single line in the email body.
2506 Longer lines are truncated at that length with ' [...]'
2507 appended.
2509 strict_utf8 (bool)
2511 If this field is set to True, then the email body text is
2512 expected to be UTF-8. Any invalid characters are
2513 converted to U+FFFD, the Unicode replacement character
2514 (encoded as UTF-8, of course).
2518 def __init__(self, strict_utf8=True, emailmaxlinelength=500, **kw):
2519 super(FilterLinesEnvironmentMixin, self).__init__(**kw)
2520 self.__strict_utf8 = strict_utf8
2521 self.__emailmaxlinelength = emailmaxlinelength
2523 def filter_body(self, lines):
2524 lines = super(FilterLinesEnvironmentMixin, self).filter_body(lines)
2525 if self.__strict_utf8:
2526 if not PYTHON3:
2527 lines = (line.decode(ENCODING, 'replace') for line in lines)
2528 # Limit the line length in Unicode-space to avoid
2529 # splitting characters:
2530 if self.__emailmaxlinelength:
2531 lines = limit_linelength(lines, self.__emailmaxlinelength)
2532 if not PYTHON3:
2533 lines = (line.encode(ENCODING, 'replace') for line in lines)
2534 elif self.__emailmaxlinelength:
2535 lines = limit_linelength(lines, self.__emailmaxlinelength)
2537 return lines
2540 class ConfigFilterLinesEnvironmentMixin(
2541 ConfigEnvironmentMixin,
2542 FilterLinesEnvironmentMixin,
2544 """Handle encoding and maximum line length based on config."""
2546 def __init__(self, config, **kw):
2547 strict_utf8 = config.get_bool('emailstrictutf8', default=None)
2548 if strict_utf8 is not None:
2549 kw['strict_utf8'] = strict_utf8
2551 emailmaxlinelength = config.get('emailmaxlinelength')
2552 if emailmaxlinelength is not None:
2553 kw['emailmaxlinelength'] = int(emailmaxlinelength)
2555 super(ConfigFilterLinesEnvironmentMixin, self).__init__(
2556 config=config, **kw
2560 class MaxlinesEnvironmentMixin(Environment):
2561 """Limit the email body to a specified number of lines."""
2563 def __init__(self, emailmaxlines, **kw):
2564 super(MaxlinesEnvironmentMixin, self).__init__(**kw)
2565 self.__emailmaxlines = emailmaxlines
2567 def filter_body(self, lines):
2568 lines = super(MaxlinesEnvironmentMixin, self).filter_body(lines)
2569 if self.__emailmaxlines:
2570 lines = limit_lines(lines, self.__emailmaxlines)
2571 return lines
2574 class ConfigMaxlinesEnvironmentMixin(
2575 ConfigEnvironmentMixin,
2576 MaxlinesEnvironmentMixin,
2578 """Limit the email body to the number of lines specified in config."""
2580 def __init__(self, config, **kw):
2581 emailmaxlines = int(config.get('emailmaxlines', default='0'))
2582 super(ConfigMaxlinesEnvironmentMixin, self).__init__(
2583 config=config,
2584 emailmaxlines=emailmaxlines,
2585 **kw
2589 class FQDNEnvironmentMixin(Environment):
2590 """A mixin that sets the host's FQDN to its constructor argument."""
2592 def __init__(self, fqdn, **kw):
2593 super(FQDNEnvironmentMixin, self).__init__(**kw)
2594 self.COMPUTED_KEYS += ['fqdn']
2595 self.__fqdn = fqdn
2597 def get_fqdn(self):
2598 """Return the fully-qualified domain name for this host.
2600 Return None if it is unavailable or unwanted."""
2602 return self.__fqdn
2605 class ConfigFQDNEnvironmentMixin(
2606 ConfigEnvironmentMixin,
2607 FQDNEnvironmentMixin,
2609 """Read the FQDN from the config."""
2611 def __init__(self, config, **kw):
2612 fqdn = config.get('fqdn')
2613 super(ConfigFQDNEnvironmentMixin, self).__init__(
2614 config=config,
2615 fqdn=fqdn,
2616 **kw
2620 class ComputeFQDNEnvironmentMixin(FQDNEnvironmentMixin):
2621 """Get the FQDN by calling socket.getfqdn()."""
2623 def __init__(self, **kw):
2624 super(ComputeFQDNEnvironmentMixin, self).__init__(
2625 fqdn=socket.getfqdn(),
2626 **kw
2630 class PusherDomainEnvironmentMixin(ConfigEnvironmentMixin):
2631 """Deduce pusher_email from pusher by appending an emaildomain."""
2633 def __init__(self, **kw):
2634 super(PusherDomainEnvironmentMixin, self).__init__(**kw)
2635 self.__emaildomain = self.config.get('emaildomain')
2637 def get_pusher_email(self):
2638 if self.__emaildomain:
2639 # Derive the pusher's full email address in the default way:
2640 return '%s@%s' % (self.get_pusher(), self.__emaildomain)
2641 else:
2642 return super(PusherDomainEnvironmentMixin, self).get_pusher_email()
2645 class StaticRecipientsEnvironmentMixin(Environment):
2646 """Set recipients statically based on constructor parameters."""
2648 def __init__(
2649 self,
2650 refchange_recipients, announce_recipients, revision_recipients, scancommitforcc,
2651 **kw
2653 super(StaticRecipientsEnvironmentMixin, self).__init__(**kw)
2655 # The recipients for various types of notification emails, as
2656 # RFC 2822 email addresses separated by commas (or the empty
2657 # string if no recipients are configured). Although there is
2658 # a mechanism to choose the recipient lists based on on the
2659 # actual *contents* of the change being reported, we only
2660 # choose based on the *type* of the change. Therefore we can
2661 # compute them once and for all:
2662 if not (refchange_recipients or
2663 announce_recipients or
2664 revision_recipients or
2665 scancommitforcc):
2666 raise ConfigurationException('No email recipients configured!')
2667 self.__refchange_recipients = refchange_recipients
2668 self.__announce_recipients = announce_recipients
2669 self.__revision_recipients = revision_recipients
2671 def get_refchange_recipients(self, refchange):
2672 return self.__refchange_recipients
2674 def get_announce_recipients(self, annotated_tag_change):
2675 return self.__announce_recipients
2677 def get_revision_recipients(self, revision):
2678 return self.__revision_recipients
2681 class ConfigRecipientsEnvironmentMixin(
2682 ConfigEnvironmentMixin,
2683 StaticRecipientsEnvironmentMixin
2685 """Determine recipients statically based on config."""
2687 def __init__(self, config, **kw):
2688 super(ConfigRecipientsEnvironmentMixin, self).__init__(
2689 config=config,
2690 refchange_recipients=self._get_recipients(
2691 config, 'refchangelist', 'mailinglist',
2693 announce_recipients=self._get_recipients(
2694 config, 'announcelist', 'refchangelist', 'mailinglist',
2696 revision_recipients=self._get_recipients(
2697 config, 'commitlist', 'mailinglist',
2699 scancommitforcc=config.get('scancommitforcc'),
2700 **kw
2703 def _get_recipients(self, config, *names):
2704 """Return the recipients for a particular type of message.
2706 Return the list of email addresses to which a particular type
2707 of notification email should be sent, by looking at the config
2708 value for "multimailhook.$name" for each of names. Use the
2709 value from the first name that is configured. The return
2710 value is a (possibly empty) string containing RFC 2822 email
2711 addresses separated by commas. If no configuration could be
2712 found, raise a ConfigurationException."""
2714 for name in names:
2715 lines = config.get_all(name)
2716 if lines is not None:
2717 lines = [line.strip() for line in lines]
2718 # Single "none" is a special value equivalen to empty string.
2719 if lines == ['none']:
2720 lines = ['']
2721 return ', '.join(lines)
2722 else:
2723 return ''
2726 class StaticRefFilterEnvironmentMixin(Environment):
2727 """Set branch filter statically based on constructor parameters."""
2729 def __init__(self, ref_filter_incl_regex, ref_filter_excl_regex,
2730 ref_filter_do_send_regex, ref_filter_dont_send_regex,
2731 **kw):
2732 super(StaticRefFilterEnvironmentMixin, self).__init__(**kw)
2734 if ref_filter_incl_regex and ref_filter_excl_regex:
2735 raise ConfigurationException(
2736 "Cannot specify both a ref inclusion and exclusion regex.")
2737 self.__is_inclusion_filter = bool(ref_filter_incl_regex)
2738 default_exclude = self.get_default_ref_ignore_regex()
2739 if ref_filter_incl_regex:
2740 ref_filter_regex = ref_filter_incl_regex
2741 elif ref_filter_excl_regex:
2742 ref_filter_regex = ref_filter_excl_regex + '|' + default_exclude
2743 else:
2744 ref_filter_regex = default_exclude
2745 try:
2746 self.__compiled_regex = re.compile(ref_filter_regex)
2747 except Exception:
2748 raise ConfigurationException(
2749 'Invalid Ref Filter Regex "%s": %s' % (ref_filter_regex, sys.exc_info()[1]))
2751 if ref_filter_do_send_regex and ref_filter_dont_send_regex:
2752 raise ConfigurationException(
2753 "Cannot specify both a ref doSend and dontSend regex.")
2754 if ref_filter_do_send_regex or ref_filter_dont_send_regex:
2755 self.__is_do_send_filter = bool(ref_filter_do_send_regex)
2756 if ref_filter_incl_regex:
2757 ref_filter_send_regex = ref_filter_incl_regex
2758 elif ref_filter_excl_regex:
2759 ref_filter_send_regex = ref_filter_excl_regex
2760 else:
2761 ref_filter_send_regex = '.*'
2762 self.__is_do_send_filter = True
2763 try:
2764 self.__send_compiled_regex = re.compile(ref_filter_send_regex)
2765 except Exception:
2766 raise ConfigurationException(
2767 'Invalid Ref Filter Regex "%s": %s' %
2768 (ref_filter_send_regex, sys.exc_info()[1]))
2769 else:
2770 self.__send_compiled_regex = self.__compiled_regex
2771 self.__is_do_send_filter = self.__is_inclusion_filter
2773 def get_ref_filter_regex(self, send_filter=False):
2774 if send_filter:
2775 return self.__send_compiled_regex, self.__is_do_send_filter
2776 else:
2777 return self.__compiled_regex, self.__is_inclusion_filter
2780 class ConfigRefFilterEnvironmentMixin(
2781 ConfigEnvironmentMixin,
2782 StaticRefFilterEnvironmentMixin
2784 """Determine branch filtering statically based on config."""
2786 def _get_regex(self, config, key):
2787 """Get a list of whitespace-separated regex. The refFilter* config
2788 variables are multivalued (hence the use of get_all), and we
2789 allow each entry to be a whitespace-separated list (hence the
2790 split on each line). The whole thing is glued into a single regex."""
2791 values = config.get_all(key)
2792 if values is None:
2793 return values
2794 items = []
2795 for line in values:
2796 for i in line.split():
2797 items.append(i)
2798 if items == []:
2799 return None
2800 return '|'.join(items)
2802 def __init__(self, config, **kw):
2803 super(ConfigRefFilterEnvironmentMixin, self).__init__(
2804 config=config,
2805 ref_filter_incl_regex=self._get_regex(config, 'refFilterInclusionRegex'),
2806 ref_filter_excl_regex=self._get_regex(config, 'refFilterExclusionRegex'),
2807 ref_filter_do_send_regex=self._get_regex(config, 'refFilterDoSendRegex'),
2808 ref_filter_dont_send_regex=self._get_regex(config, 'refFilterDontSendRegex'),
2809 **kw
2813 class ProjectdescEnvironmentMixin(Environment):
2814 """Make a "projectdesc" value available for templates.
2816 By default, it is set to the first line of $GIT_DIR/description
2817 (if that file is present and appears to be set meaningfully)."""
2819 def __init__(self, **kw):
2820 super(ProjectdescEnvironmentMixin, self).__init__(**kw)
2821 self.COMPUTED_KEYS += ['projectdesc']
2823 def get_projectdesc(self):
2824 """Return a one-line descripition of the project."""
2826 git_dir = get_git_dir()
2827 try:
2828 projectdesc = open(os.path.join(git_dir, 'description')).readline().strip()
2829 if projectdesc and not projectdesc.startswith('Unnamed repository'):
2830 return projectdesc
2831 except IOError:
2832 pass
2834 return 'UNNAMED PROJECT'
2837 class GenericEnvironmentMixin(Environment):
2838 def get_pusher(self):
2839 return self.osenv.get('USER', self.osenv.get('USERNAME', 'unknown user'))
2842 class GenericEnvironment(
2843 ProjectdescEnvironmentMixin,
2844 ConfigMaxlinesEnvironmentMixin,
2845 ComputeFQDNEnvironmentMixin,
2846 ConfigFilterLinesEnvironmentMixin,
2847 ConfigRecipientsEnvironmentMixin,
2848 ConfigRefFilterEnvironmentMixin,
2849 PusherDomainEnvironmentMixin,
2850 ConfigOptionsEnvironmentMixin,
2851 GenericEnvironmentMixin,
2852 Environment,
2854 pass
2857 class GitoliteEnvironmentMixin(Environment):
2858 def get_repo_shortname(self):
2859 # The gitolite environment variable $GL_REPO is a pretty good
2860 # repo_shortname (though it's probably not as good as a value
2861 # the user might have explicitly put in his config).
2862 return (
2863 self.osenv.get('GL_REPO', None) or
2864 super(GitoliteEnvironmentMixin, self).get_repo_shortname()
2867 def get_pusher(self):
2868 return self.osenv.get('GL_USER', 'unknown user')
2870 def get_fromaddr(self, change=None):
2871 GL_USER = self.osenv.get('GL_USER')
2872 if GL_USER is not None:
2873 # Find the path to gitolite.conf. Note that gitolite v3
2874 # did away with the GL_ADMINDIR and GL_CONF environment
2875 # variables (they are now hard-coded).
2876 GL_ADMINDIR = self.osenv.get(
2877 'GL_ADMINDIR',
2878 os.path.expanduser(os.path.join('~', '.gitolite')))
2879 GL_CONF = self.osenv.get(
2880 'GL_CONF',
2881 os.path.join(GL_ADMINDIR, 'conf', 'gitolite.conf'))
2882 if os.path.isfile(GL_CONF):
2883 f = open(GL_CONF, 'rU')
2884 try:
2885 in_user_emails_section = False
2886 re_template = r'^\s*#\s*%s\s*$'
2887 re_begin, re_user, re_end = (
2888 re.compile(re_template % x)
2889 for x in (
2890 r'BEGIN\s+USER\s+EMAILS',
2891 re.escape(GL_USER) + r'\s+(.*)',
2892 r'END\s+USER\s+EMAILS',
2894 for l in f:
2895 l = l.rstrip('\n')
2896 if not in_user_emails_section:
2897 if re_begin.match(l):
2898 in_user_emails_section = True
2899 continue
2900 if re_end.match(l):
2901 break
2902 m = re_user.match(l)
2903 if m:
2904 return m.group(1)
2905 finally:
2906 f.close()
2907 return super(GitoliteEnvironmentMixin, self).get_fromaddr(change)
2910 class IncrementalDateTime(object):
2911 """Simple wrapper to give incremental date/times.
2913 Each call will result in a date/time a second later than the
2914 previous call. This can be used to falsify email headers, to
2915 increase the likelihood that email clients sort the emails
2916 correctly."""
2918 def __init__(self):
2919 self.time = time.time()
2920 self.next = self.__next__ # Python 2 backward compatibility
2922 def __next__(self):
2923 formatted = formatdate(self.time, True)
2924 self.time += 1
2925 return formatted
2928 class GitoliteEnvironment(
2929 ProjectdescEnvironmentMixin,
2930 ConfigMaxlinesEnvironmentMixin,
2931 ComputeFQDNEnvironmentMixin,
2932 ConfigFilterLinesEnvironmentMixin,
2933 ConfigRecipientsEnvironmentMixin,
2934 ConfigRefFilterEnvironmentMixin,
2935 PusherDomainEnvironmentMixin,
2936 ConfigOptionsEnvironmentMixin,
2937 GitoliteEnvironmentMixin,
2938 Environment,
2940 pass
2943 class StashEnvironmentMixin(Environment):
2944 def __init__(self, user=None, repo=None, **kw):
2945 super(StashEnvironmentMixin, self).__init__(**kw)
2946 self.__user = user
2947 self.__repo = repo
2949 def get_repo_shortname(self):
2950 return self.__repo
2952 def get_pusher(self):
2953 return re.match('(.*?)\s*<', self.__user).group(1)
2955 def get_pusher_email(self):
2956 return self.__user
2958 def get_fromaddr(self, change=None):
2959 return self.__user
2962 class StashEnvironment(
2963 StashEnvironmentMixin,
2964 ProjectdescEnvironmentMixin,
2965 ConfigMaxlinesEnvironmentMixin,
2966 ComputeFQDNEnvironmentMixin,
2967 ConfigFilterLinesEnvironmentMixin,
2968 ConfigRecipientsEnvironmentMixin,
2969 ConfigRefFilterEnvironmentMixin,
2970 PusherDomainEnvironmentMixin,
2971 ConfigOptionsEnvironmentMixin,
2972 Environment,
2974 pass
2977 class GerritEnvironmentMixin(Environment):
2978 def __init__(self, project=None, submitter=None, update_method=None, **kw):
2979 super(GerritEnvironmentMixin, self).__init__(**kw)
2980 self.__project = project
2981 self.__submitter = submitter
2982 self.__update_method = update_method
2983 "Make an 'update_method' value available for templates."
2984 self.COMPUTED_KEYS += ['update_method']
2986 def get_repo_shortname(self):
2987 return self.__project
2989 def get_pusher(self):
2990 if self.__submitter:
2991 if self.__submitter.find('<') != -1:
2992 # Submitter has a configured email, we transformed
2993 # __submitter into an RFC 2822 string already.
2994 return re.match('(.*?)\s*<', self.__submitter).group(1)
2995 else:
2996 # Submitter has no configured email, it's just his name.
2997 return self.__submitter
2998 else:
2999 # If we arrive here, this means someone pushed "Submit" from
3000 # the gerrit web UI for the CR (or used one of the programmatic
3001 # APIs to do the same, such as gerrit review) and the
3002 # merge/push was done by the Gerrit user. It was technically
3003 # triggered by someone else, but sadly we have no way of
3004 # determining who that someone else is at this point.
3005 return 'Gerrit' # 'unknown user'?
3007 def get_pusher_email(self):
3008 if self.__submitter:
3009 return self.__submitter
3010 else:
3011 return super(GerritEnvironmentMixin, self).get_pusher_email()
3013 def get_fromaddr(self, change=None):
3014 if self.__submitter and self.__submitter.find('<') != -1:
3015 return self.__submitter
3016 else:
3017 return super(GerritEnvironmentMixin, self).get_fromaddr(change)
3019 def get_default_ref_ignore_regex(self):
3020 default = super(GerritEnvironmentMixin, self).get_default_ref_ignore_regex()
3021 return default + '|^refs/changes/|^refs/cache-automerge/|^refs/meta/'
3023 def get_revision_recipients(self, revision):
3024 # Merge commits created by Gerrit when users hit "Submit this patchset"
3025 # in the Web UI (or do equivalently with REST APIs or the gerrit review
3026 # command) are not something users want to see an individual email for.
3027 # Filter them out.
3028 committer = read_git_output(['log', '--no-walk', '--format=%cN',
3029 revision.rev.sha1])
3030 if committer == 'Gerrit Code Review':
3031 return []
3032 else:
3033 return super(GerritEnvironmentMixin, self).get_revision_recipients(revision)
3035 def get_update_method(self):
3036 return self.__update_method
3039 class GerritEnvironment(
3040 GerritEnvironmentMixin,
3041 ProjectdescEnvironmentMixin,
3042 ConfigMaxlinesEnvironmentMixin,
3043 ComputeFQDNEnvironmentMixin,
3044 ConfigFilterLinesEnvironmentMixin,
3045 ConfigRecipientsEnvironmentMixin,
3046 ConfigRefFilterEnvironmentMixin,
3047 PusherDomainEnvironmentMixin,
3048 ConfigOptionsEnvironmentMixin,
3049 Environment,
3051 pass
3054 class Push(object):
3055 """Represent an entire push (i.e., a group of ReferenceChanges).
3057 It is easy to figure out what commits were added to a *branch* by
3058 a Reference change:
3060 git rev-list change.old..change.new
3062 or removed from a *branch*:
3064 git rev-list change.new..change.old
3066 But it is not quite so trivial to determine which entirely new
3067 commits were added to the *repository* by a push and which old
3068 commits were discarded by a push. A big part of the job of this
3069 class is to figure out these things, and to make sure that new
3070 commits are only detailed once even if they were added to multiple
3071 references.
3073 The first step is to determine the "other" references--those
3074 unaffected by the current push. They are computed by listing all
3075 references then removing any affected by this push. The results
3076 are stored in Push._other_ref_sha1s.
3078 The commits contained in the repository before this push were
3080 git rev-list other1 other2 other3 ... change1.old change2.old ...
3082 Where "changeN.old" is the old value of one of the references
3083 affected by this push.
3085 The commits contained in the repository after this push are
3087 git rev-list other1 other2 other3 ... change1.new change2.new ...
3089 The commits added by this push are the difference between these
3090 two sets, which can be written
3092 git rev-list \
3093 ^other1 ^other2 ... \
3094 ^change1.old ^change2.old ... \
3095 change1.new change2.new ...
3097 The commits removed by this push can be computed by
3099 git rev-list \
3100 ^other1 ^other2 ... \
3101 ^change1.new ^change2.new ... \
3102 change1.old change2.old ...
3104 The last point is that it is possible that other pushes are
3105 occurring simultaneously to this one, so reference values can
3106 change at any time. It is impossible to eliminate all race
3107 conditions, but we reduce the window of time during which problems
3108 can occur by translating reference names to SHA1s as soon as
3109 possible and working with SHA1s thereafter (because SHA1s are
3110 immutable)."""
3112 # A map {(changeclass, changetype): integer} specifying the order
3113 # that reference changes will be processed if multiple reference
3114 # changes are included in a single push. The order is significant
3115 # mostly because new commit notifications are threaded together
3116 # with the first reference change that includes the commit. The
3117 # following order thus causes commits to be grouped with branch
3118 # changes (as opposed to tag changes) if possible.
3119 SORT_ORDER = dict(
3120 (value, i) for (i, value) in enumerate([
3121 (BranchChange, 'update'),
3122 (BranchChange, 'create'),
3123 (AnnotatedTagChange, 'update'),
3124 (AnnotatedTagChange, 'create'),
3125 (NonAnnotatedTagChange, 'update'),
3126 (NonAnnotatedTagChange, 'create'),
3127 (BranchChange, 'delete'),
3128 (AnnotatedTagChange, 'delete'),
3129 (NonAnnotatedTagChange, 'delete'),
3130 (OtherReferenceChange, 'update'),
3131 (OtherReferenceChange, 'create'),
3132 (OtherReferenceChange, 'delete'),
3136 def __init__(self, environment, changes, ignore_other_refs=False):
3137 self.changes = sorted(changes, key=self._sort_key)
3138 self.__other_ref_sha1s = None
3139 self.__cached_commits_spec = {}
3140 self.environment = environment
3142 if ignore_other_refs:
3143 self.__other_ref_sha1s = set()
3145 @classmethod
3146 def _sort_key(klass, change):
3147 return (klass.SORT_ORDER[change.__class__, change.change_type], change.refname,)
3149 @property
3150 def _other_ref_sha1s(self):
3151 """The GitObjects referred to by references unaffected by this push.
3153 if self.__other_ref_sha1s is None:
3154 # The refnames being changed by this push:
3155 updated_refs = set(
3156 change.refname
3157 for change in self.changes
3160 # The SHA-1s of commits referred to by all references in this
3161 # repository *except* updated_refs:
3162 sha1s = set()
3163 fmt = (
3164 '%(objectname) %(objecttype) %(refname)\n'
3165 '%(*objectname) %(*objecttype) %(refname)'
3167 ref_filter_regex, is_inclusion_filter = \
3168 self.environment.get_ref_filter_regex()
3169 for line in read_git_lines(
3170 ['for-each-ref', '--format=%s' % (fmt,)]):
3171 (sha1, type, name) = line.split(' ', 2)
3172 if (sha1 and type == 'commit' and
3173 name not in updated_refs and
3174 include_ref(name, ref_filter_regex, is_inclusion_filter)):
3175 sha1s.add(sha1)
3177 self.__other_ref_sha1s = sha1s
3179 return self.__other_ref_sha1s
3181 def _get_commits_spec_incl(self, new_or_old, reference_change=None):
3182 """Get new or old SHA-1 from one or each of the changed refs.
3184 Return a list of SHA-1 commit identifier strings suitable as
3185 arguments to 'git rev-list' (or 'git log' or ...). The
3186 returned identifiers are either the old or new values from one
3187 or all of the changed references, depending on the values of
3188 new_or_old and reference_change.
3190 new_or_old is either the string 'new' or the string 'old'. If
3191 'new', the returned SHA-1 identifiers are the new values from
3192 each changed reference. If 'old', the SHA-1 identifiers are
3193 the old values from each changed reference.
3195 If reference_change is specified and not None, only the new or
3196 old reference from the specified reference is included in the
3197 return value.
3199 This function returns None if there are no matching revisions
3200 (e.g., because a branch was deleted and new_or_old is 'new').
3203 if not reference_change:
3204 incl_spec = sorted(
3205 getattr(change, new_or_old).sha1
3206 for change in self.changes
3207 if getattr(change, new_or_old)
3209 if not incl_spec:
3210 incl_spec = None
3211 elif not getattr(reference_change, new_or_old).commit_sha1:
3212 incl_spec = None
3213 else:
3214 incl_spec = [getattr(reference_change, new_or_old).commit_sha1]
3215 return incl_spec
3217 def _get_commits_spec_excl(self, new_or_old):
3218 """Get exclusion revisions for determining new or discarded commits.
3220 Return a list of strings suitable as arguments to 'git
3221 rev-list' (or 'git log' or ...) that will exclude all
3222 commits that, depending on the value of new_or_old, were
3223 either previously in the repository (useful for determining
3224 which commits are new to the repository) or currently in the
3225 repository (useful for determining which commits were
3226 discarded from the repository).
3228 new_or_old is either the string 'new' or the string 'old'. If
3229 'new', the commits to be excluded are those that were in the
3230 repository before the push. If 'old', the commits to be
3231 excluded are those that are currently in the repository. """
3233 old_or_new = {'old': 'new', 'new': 'old'}[new_or_old]
3234 excl_revs = self._other_ref_sha1s.union(
3235 getattr(change, old_or_new).sha1
3236 for change in self.changes
3237 if getattr(change, old_or_new).type in ['commit', 'tag']
3239 return ['^' + sha1 for sha1 in sorted(excl_revs)]
3241 def get_commits_spec(self, new_or_old, reference_change=None):
3242 """Get rev-list arguments for added or discarded commits.
3244 Return a list of strings suitable as arguments to 'git
3245 rev-list' (or 'git log' or ...) that select those commits
3246 that, depending on the value of new_or_old, are either new to
3247 the repository or were discarded from the repository.
3249 new_or_old is either the string 'new' or the string 'old'. If
3250 'new', the returned list is used to select commits that are
3251 new to the repository. If 'old', the returned value is used
3252 to select the commits that have been discarded from the
3253 repository.
3255 If reference_change is specified and not None, the new or
3256 discarded commits are limited to those that are reachable from
3257 the new or old value of the specified reference.
3259 This function returns None if there are no added (or discarded)
3260 revisions.
3262 key = (new_or_old, reference_change)
3263 if key not in self.__cached_commits_spec:
3264 ret = self._get_commits_spec_incl(new_or_old, reference_change)
3265 if ret is not None:
3266 ret.extend(self._get_commits_spec_excl(new_or_old))
3267 self.__cached_commits_spec[key] = ret
3268 return self.__cached_commits_spec[key]
3270 def get_new_commits(self, reference_change=None):
3271 """Return a list of commits added by this push.
3273 Return a list of the object names of commits that were added
3274 by the part of this push represented by reference_change. If
3275 reference_change is None, then return a list of *all* commits
3276 added by this push."""
3278 spec = self.get_commits_spec('new', reference_change)
3279 return git_rev_list(spec)
3281 def get_discarded_commits(self, reference_change):
3282 """Return a list of commits discarded by this push.
3284 Return a list of the object names of commits that were
3285 entirely discarded from the repository by the part of this
3286 push represented by reference_change."""
3288 spec = self.get_commits_spec('old', reference_change)
3289 return git_rev_list(spec)
3291 def send_emails(self, mailer, body_filter=None):
3292 """Use send all of the notification emails needed for this push.
3294 Use send all of the notification emails (including reference
3295 change emails and commit emails) needed for this push. Send
3296 the emails using mailer. If body_filter is not None, then use
3297 it to filter the lines that are intended for the email
3298 body."""
3300 # The sha1s of commits that were introduced by this push.
3301 # They will be removed from this set as they are processed, to
3302 # guarantee that one (and only one) email is generated for
3303 # each new commit.
3304 unhandled_sha1s = set(self.get_new_commits())
3305 send_date = IncrementalDateTime()
3306 for change in self.changes:
3307 sha1s = []
3308 for sha1 in reversed(list(self.get_new_commits(change))):
3309 if sha1 in unhandled_sha1s:
3310 sha1s.append(sha1)
3311 unhandled_sha1s.remove(sha1)
3313 # Check if we've got anyone to send to
3314 if not change.recipients:
3315 change.environment.log_warning(
3316 '*** no recipients configured so no email will be sent\n'
3317 '*** for %r update %s->%s\n'
3318 % (change.refname, change.old.sha1, change.new.sha1,)
3320 else:
3321 if not change.environment.quiet:
3322 change.environment.log_msg(
3323 'Sending notification emails to: %s\n' % (change.recipients,))
3324 extra_values = {'send_date': next(send_date)}
3326 rev = change.send_single_combined_email(sha1s)
3327 if rev:
3328 mailer.send(
3329 change.generate_combined_email(self, rev, body_filter, extra_values),
3330 rev.recipients,
3332 # This change is now fully handled; no need to handle
3333 # individual revisions any further.
3334 continue
3335 else:
3336 mailer.send(
3337 change.generate_email(self, body_filter, extra_values),
3338 change.recipients,
3341 max_emails = change.environment.maxcommitemails
3342 if max_emails and len(sha1s) > max_emails:
3343 change.environment.log_warning(
3344 '*** Too many new commits (%d), not sending commit emails.\n' % len(sha1s) +
3345 '*** Try setting multimailhook.maxCommitEmails to a greater value\n' +
3346 '*** Currently, multimailhook.maxCommitEmails=%d\n' % max_emails
3348 return
3350 for (num, sha1) in enumerate(sha1s):
3351 rev = Revision(change, GitObject(sha1), num=num + 1, tot=len(sha1s))
3352 if not rev.recipients and rev.cc_recipients:
3353 change.environment.log_msg('*** Replacing Cc: with To:\n')
3354 rev.recipients = rev.cc_recipients
3355 rev.cc_recipients = None
3356 if rev.recipients:
3357 extra_values = {'send_date': next(send_date)}
3358 mailer.send(
3359 rev.generate_email(self, body_filter, extra_values),
3360 rev.recipients,
3363 # Consistency check:
3364 if unhandled_sha1s:
3365 change.environment.log_error(
3366 'ERROR: No emails were sent for the following new commits:\n'
3367 ' %s\n'
3368 % ('\n '.join(sorted(unhandled_sha1s)),)
3372 def include_ref(refname, ref_filter_regex, is_inclusion_filter):
3373 does_match = bool(ref_filter_regex.search(refname))
3374 if is_inclusion_filter:
3375 return does_match
3376 else: # exclusion filter -- we include the ref if the regex doesn't match
3377 return not does_match
3380 def run_as_post_receive_hook(environment, mailer):
3381 ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(True)
3382 changes = []
3383 for line in sys.stdin:
3384 (oldrev, newrev, refname) = line.strip().split(' ', 2)
3385 if not include_ref(refname, ref_filter_regex, is_inclusion_filter):
3386 continue
3387 changes.append(
3388 ReferenceChange.create(environment, oldrev, newrev, refname)
3390 if changes:
3391 push = Push(environment, changes)
3392 push.send_emails(mailer, body_filter=environment.filter_body)
3395 def run_as_update_hook(environment, mailer, refname, oldrev, newrev, force_send=False):
3396 ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(True)
3397 if not include_ref(refname, ref_filter_regex, is_inclusion_filter):
3398 return
3399 changes = [
3400 ReferenceChange.create(
3401 environment,
3402 read_git_output(['rev-parse', '--verify', oldrev]),
3403 read_git_output(['rev-parse', '--verify', newrev]),
3404 refname,
3407 push = Push(environment, changes, force_send)
3408 push.send_emails(mailer, body_filter=environment.filter_body)
3411 def choose_mailer(config, environment):
3412 mailer = config.get('mailer', default='sendmail')
3414 if mailer == 'smtp':
3415 smtpserver = config.get('smtpserver', default='localhost')
3416 smtpservertimeout = float(config.get('smtpservertimeout', default=10.0))
3417 smtpserverdebuglevel = int(config.get('smtpserverdebuglevel', default=0))
3418 smtpencryption = config.get('smtpencryption', default='none')
3419 smtpuser = config.get('smtpuser', default='')
3420 smtppass = config.get('smtppass', default='')
3421 mailer = SMTPMailer(
3422 envelopesender=(environment.get_sender() or environment.get_fromaddr()),
3423 smtpserver=smtpserver, smtpservertimeout=smtpservertimeout,
3424 smtpserverdebuglevel=smtpserverdebuglevel,
3425 smtpencryption=smtpencryption,
3426 smtpuser=smtpuser,
3427 smtppass=smtppass,
3429 elif mailer == 'sendmail':
3430 command = config.get('sendmailcommand')
3431 if command:
3432 command = shlex.split(command)
3433 mailer = SendMailer(command=command, envelopesender=environment.get_sender())
3434 else:
3435 environment.log_error(
3436 'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n' % mailer +
3437 'please use one of "smtp" or "sendmail".\n'
3439 sys.exit(1)
3440 return mailer
3443 KNOWN_ENVIRONMENTS = {
3444 'generic': GenericEnvironmentMixin,
3445 'gitolite': GitoliteEnvironmentMixin,
3446 'stash': StashEnvironmentMixin,
3447 'gerrit': GerritEnvironmentMixin,
3451 def choose_environment(config, osenv=None, env=None, recipients=None,
3452 hook_info=None):
3453 if not osenv:
3454 osenv = os.environ
3456 environment_mixins = [
3457 ConfigRefFilterEnvironmentMixin,
3458 ProjectdescEnvironmentMixin,
3459 ConfigMaxlinesEnvironmentMixin,
3460 ComputeFQDNEnvironmentMixin,
3461 ConfigFilterLinesEnvironmentMixin,
3462 PusherDomainEnvironmentMixin,
3463 ConfigOptionsEnvironmentMixin,
3465 environment_kw = {
3466 'osenv': osenv,
3467 'config': config,
3470 if not env:
3471 env = config.get('environment')
3473 if not env:
3474 if 'GL_USER' in osenv and 'GL_REPO' in osenv:
3475 env = 'gitolite'
3476 else:
3477 env = 'generic'
3479 environment_mixins.insert(0, KNOWN_ENVIRONMENTS[env])
3481 if env == 'stash':
3482 environment_kw['user'] = hook_info['stash_user']
3483 environment_kw['repo'] = hook_info['stash_repo']
3484 elif env == 'gerrit':
3485 environment_kw['project'] = hook_info['project']
3486 environment_kw['submitter'] = hook_info['submitter']
3487 environment_kw['update_method'] = hook_info['update_method']
3489 if recipients:
3490 environment_mixins.insert(0, StaticRecipientsEnvironmentMixin)
3491 environment_kw['refchange_recipients'] = recipients
3492 environment_kw['announce_recipients'] = recipients
3493 environment_kw['revision_recipients'] = recipients
3494 environment_kw['scancommitforcc'] = config.get('scancommitforcc')
3495 else:
3496 environment_mixins.insert(0, ConfigRecipientsEnvironmentMixin)
3498 environment_klass = type(
3499 'EffectiveEnvironment',
3500 tuple(environment_mixins) + (Environment,),
3503 return environment_klass(**environment_kw)
3506 def get_version():
3507 oldcwd = os.getcwd()
3508 try:
3509 try:
3510 os.chdir(os.path.dirname(os.path.realpath(__file__)))
3511 git_version = read_git_output(['describe', '--tags', 'HEAD'])
3512 if git_version == __version__:
3513 return git_version
3514 else:
3515 return '%s (%s)' % (__version__, git_version)
3516 except:
3517 pass
3518 finally:
3519 os.chdir(oldcwd)
3520 return __version__
3523 def compute_gerrit_options(options, args, required_gerrit_options):
3524 if None in required_gerrit_options:
3525 raise SystemExit("Error: Specify all of --oldrev, --newrev, --refname, "
3526 "and --project; or none of them.")
3528 if options.environment not in (None, 'gerrit'):
3529 raise SystemExit("Non-gerrit environments incompatible with --oldrev, "
3530 "--newrev, --refname, and --project")
3531 options.environment = 'gerrit'
3533 if args:
3534 raise SystemExit("Error: Positional parameters not allowed with "
3535 "--oldrev, --newrev, and --refname.")
3537 # Gerrit oddly omits 'refs/heads/' in the refname when calling
3538 # ref-updated hook; put it back.
3539 git_dir = get_git_dir()
3540 if (not os.path.exists(os.path.join(git_dir, options.refname)) and
3541 os.path.exists(os.path.join(git_dir, 'refs', 'heads',
3542 options.refname))):
3543 options.refname = 'refs/heads/' + options.refname
3545 # Convert each string option unicode for Python3.
3546 if PYTHON3:
3547 opts = ['environment', 'recipients', 'oldrev', 'newrev', 'refname',
3548 'project', 'submitter', 'stash-user', 'stash-repo']
3549 for opt in opts:
3550 if not hasattr(options, opt):
3551 continue
3552 obj = getattr(options, opt)
3553 if obj:
3554 enc = obj.encode('utf-8', 'surrogateescape')
3555 dec = enc.decode('utf-8', 'replace')
3556 setattr(options, opt, dec)
3558 # New revisions can appear in a gerrit repository either due to someone
3559 # pushing directly (in which case options.submitter will be set), or they
3560 # can press "Submit this patchset" in the web UI for some CR (in which
3561 # case options.submitter will not be set and gerrit will not have provided
3562 # us the information about who pressed the button).
3564 # Note for the nit-picky: I'm lumping in REST API calls and the ssh
3565 # gerrit review command in with "Submit this patchset" button, since they
3566 # have the same effect.
3567 if options.submitter:
3568 update_method = 'pushed'
3569 # The submitter argument is almost an RFC 2822 email address; change it
3570 # from 'User Name (email@domain)' to 'User Name <email@domain>' so it is
3571 options.submitter = options.submitter.replace('(', '<').replace(')', '>')
3572 else:
3573 update_method = 'submitted'
3574 # Gerrit knew who submitted this patchset, but threw that information
3575 # away when it invoked this hook. However, *IF* Gerrit created a
3576 # merge to bring the patchset in (project 'Submit Type' is either
3577 # "Always Merge", or is "Merge if Necessary" and happens to be
3578 # necessary for this particular CR), then it will have the committer
3579 # of that merge be 'Gerrit Code Review' and the author will be the
3580 # person who requested the submission of the CR. Since this is fairly
3581 # likely for most gerrit installations (of a reasonable size), it's
3582 # worth the extra effort to try to determine the actual submitter.
3583 rev_info = read_git_lines(['log', '--no-walk', '--merges',
3584 '--format=%cN%n%aN <%aE>', options.newrev])
3585 if rev_info and rev_info[0] == 'Gerrit Code Review':
3586 options.submitter = rev_info[1]
3588 # We pass back refname, oldrev, newrev as args because then the
3589 # gerrit ref-updated hook is much like the git update hook
3590 return (options,
3591 [options.refname, options.oldrev, options.newrev],
3592 {'project': options.project, 'submitter': options.submitter,
3593 'update_method': update_method})
3596 def check_hook_specific_args(options, args):
3597 # First check for stash arguments
3598 if (options.stash_user is None) != (options.stash_repo is None):
3599 raise SystemExit("Error: Specify both of --stash-user and "
3600 "--stash-repo or neither.")
3601 if options.stash_user:
3602 options.environment = 'stash'
3603 return options, args, {'stash_user': options.stash_user,
3604 'stash_repo': options.stash_repo}
3606 # Finally, check for gerrit specific arguments
3607 required_gerrit_options = (options.oldrev, options.newrev, options.refname,
3608 options.project)
3609 if required_gerrit_options != (None,) * 4:
3610 return compute_gerrit_options(options, args, required_gerrit_options)
3612 # No special options in use, just return what we started with
3613 return options, args, {}
3616 def main(args):
3617 parser = optparse.OptionParser(
3618 description=__doc__,
3619 usage='%prog [OPTIONS]\n or: %prog [OPTIONS] REFNAME OLDREV NEWREV',
3622 parser.add_option(
3623 '--environment', '--env', action='store', type='choice',
3624 choices=list(KNOWN_ENVIRONMENTS.keys()), default=None,
3625 help=(
3626 'Choose type of environment is in use. Default is taken from '
3627 'multimailhook.environment if set; otherwise "generic".'
3630 parser.add_option(
3631 '--stdout', action='store_true', default=False,
3632 help='Output emails to stdout rather than sending them.',
3634 parser.add_option(
3635 '--recipients', action='store', default=None,
3636 help='Set list of email recipients for all types of emails.',
3638 parser.add_option(
3639 '--show-env', action='store_true', default=False,
3640 help=(
3641 'Write to stderr the values determined for the environment '
3642 '(intended for debugging purposes).'
3645 parser.add_option(
3646 '--force-send', action='store_true', default=False,
3647 help=(
3648 'Force sending refchange email when using as an update hook. '
3649 'This is useful to work around the unreliable new commits '
3650 'detection in this mode.'
3653 parser.add_option(
3654 '-c', metavar="<name>=<value>", action='append',
3655 help=(
3656 'Pass a configuration parameter through to git. The value given '
3657 'will override values from configuration files. See the -c option '
3658 'of git(1) for more details. (Only works with git >= 1.7.3)'
3661 parser.add_option(
3662 '--version', '-v', action='store_true', default=False,
3663 help=(
3664 "Display git-multimail's version"
3667 # The following options permit this script to be run as a gerrit
3668 # ref-updated hook. See e.g.
3669 # code.google.com/p/gerrit/source/browse/Documentation/config-hooks.txt
3670 # We suppress help for these items, since these are specific to gerrit,
3671 # and we don't want users directly using them any way other than how the
3672 # gerrit ref-updated hook is called.
3673 parser.add_option('--oldrev', action='store', help=optparse.SUPPRESS_HELP)
3674 parser.add_option('--newrev', action='store', help=optparse.SUPPRESS_HELP)
3675 parser.add_option('--refname', action='store', help=optparse.SUPPRESS_HELP)
3676 parser.add_option('--project', action='store', help=optparse.SUPPRESS_HELP)
3677 parser.add_option('--submitter', action='store', help=optparse.SUPPRESS_HELP)
3679 # The following allow this to be run as a stash asynchronous post-receive
3680 # hook (almost identical to a git post-receive hook but triggered also for
3681 # merges of pull requests from the UI). We suppress help for these items,
3682 # since these are specific to stash.
3683 parser.add_option('--stash-user', action='store', help=optparse.SUPPRESS_HELP)
3684 parser.add_option('--stash-repo', action='store', help=optparse.SUPPRESS_HELP)
3686 (options, args) = parser.parse_args(args)
3687 (options, args, hook_info) = check_hook_specific_args(options, args)
3689 if options.version:
3690 sys.stdout.write('git-multimail version ' + get_version() + '\n')
3691 return
3693 if options.c:
3694 parameters = os.environ.get('GIT_CONFIG_PARAMETERS', '')
3695 if parameters:
3696 parameters += ' '
3697 # git expects GIT_CONFIG_PARAMETERS to be of the form
3698 # "'name1=value1' 'name2=value2' 'name3=value3'"
3699 # including everything inside the double quotes (but not the double
3700 # quotes themselves). Spacing is critical. Also, if a value contains
3701 # a literal single quote that quote must be represented using the
3702 # four character sequence: '\''
3703 parameters += ' '.join("'" + x.replace("'", "'\\''") + "'" for x in options.c)
3704 os.environ['GIT_CONFIG_PARAMETERS'] = parameters
3706 config = Config('multimailhook')
3708 try:
3709 environment = choose_environment(
3710 config, osenv=os.environ,
3711 env=options.environment,
3712 recipients=options.recipients,
3713 hook_info=hook_info,
3716 if options.show_env:
3717 sys.stderr.write('Environment values:\n')
3718 for (k, v) in sorted(environment.get_values().items()):
3719 sys.stderr.write(' %s : %r\n' % (k, v))
3720 sys.stderr.write('\n')
3722 if options.stdout or environment.stdout:
3723 mailer = OutputMailer(sys.stdout)
3724 else:
3725 mailer = choose_mailer(config, environment)
3727 # Dual mode: if arguments were specified on the command line, run
3728 # like an update hook; otherwise, run as a post-receive hook.
3729 if args:
3730 if len(args) != 3:
3731 parser.error('Need zero or three non-option arguments')
3732 (refname, oldrev, newrev) = args
3733 run_as_update_hook(environment, mailer, refname, oldrev, newrev, options.force_send)
3734 else:
3735 run_as_post_receive_hook(environment, mailer)
3736 except ConfigurationException:
3737 sys.exit(sys.exc_info()[1])
3738 except Exception:
3739 t, e, tb = sys.exc_info()
3740 import traceback
3741 sys.stdout.write('\n')
3742 sys.stdout.write('Exception \'' + t.__name__ +
3743 '\' raised. Please report this as a bug to\n')
3744 sys.stdout.write('https://github.com/git-multimail/git-multimail/issues\n')
3745 sys.stdout.write('with the information below:\n\n')
3746 sys.stdout.write('git-multimail version ' + get_version() + '\n')
3747 sys.stdout.write('Python version ' + sys.version + '\n')
3748 traceback.print_exc(file=sys.stdout)
3749 sys.exit(1)
3751 if __name__ == '__main__':
3752 main(sys.argv[1:])