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
47 See the accompanying README file for the complete documentation.
63 # Python < 2.6 do not have ssl, but that's OK if we don't use it.
68 PYTHON3 = sys.version_info >= (3, 0)
70 if sys.version_info <= (2, 5):
72 for element in iterable:
79 return all(ord(c) < 128 and ord(c) > 0 for c in s)
84 return isinstance(s, str)
87 return s.encode(ENCODING)
90 return s.decode(ENCODING)
94 def write_str(f, msg):
95 # Try outputing with the default encoding. If it fails,
98 f.buffer.write(msg.encode(sys.getdefaultencoding()))
99 except UnicodeEncodeError:
100 f.buffer.write(msg.encode(ENCODING))
104 return isinstance(s, basestring)
105 except NameError: # Silence Pyflakes warning
114 def write_str(f, msg):
122 from email.charset import Charset
123 from email.utils import make_msgid
124 from email.utils import getaddresses
125 from email.utils import formataddr
126 from email.utils import formatdate
127 from email.header import Header
129 # Prior to Python 2.5, the email module used different names:
130 from email.Charset import Charset
131 from email.Utils import make_msgid
132 from email.Utils import getaddresses
133 from email.Utils import formataddr
134 from email.Utils import formatdate
135 from email.Header import Header
141 LOGBEGIN = '- Log -----------------------------------------------------------------\n'
142 LOGEND = '-----------------------------------------------------------------------\n'
144 ADDR_HEADERS = set(['from', 'to', 'cc', 'bcc', 'reply-to', 'sender'])
146 # It is assumed in many places that the encoding is uniformly UTF-8,
147 # so changing these constants is unsupported. But define them here
148 # anyway, to make it easier to find (at least most of) the places
149 # where the encoding is important.
150 (ENCODING, CHARSET) = ('UTF-8', 'utf-8')
153 REF_CREATED_SUBJECT_TEMPLATE = (
154 '%(emailprefix)s%(refname_type)s %(short_refname)s created'
155 ' (now %(newrev_short)s)'
157 REF_UPDATED_SUBJECT_TEMPLATE = (
158 '%(emailprefix)s%(refname_type)s %(short_refname)s updated'
159 ' (%(oldrev_short)s -> %(newrev_short)s)'
161 REF_DELETED_SUBJECT_TEMPLATE = (
162 '%(emailprefix)s%(refname_type)s %(short_refname)s deleted'
163 ' (was %(oldrev_short)s)'
166 COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE = (
167 '%(emailprefix)s%(refname_type)s %(short_refname)s updated: %(oneline)s'
170 REFCHANGE_HEADER_TEMPLATE = """\
175 Content-Type: text/%(contenttype)s; charset=%(charset)s
176 Content-Transfer-Encoding: 8bit
177 Message-ID: %(msgid)s
179 Reply-To: %(reply_to)s
181 X-Git-Repo: %(repo_shortname)s
182 X-Git-Refname: %(refname)s
183 X-Git-Reftype: %(refname_type)s
184 X-Git-Oldrev: %(oldrev)s
185 X-Git-Newrev: %(newrev)s
186 X-Git-NotificationType: ref_changed
187 X-Git-Multimail-Version: %(multimail_version)s
188 Auto-Submitted: auto-generated
191 REFCHANGE_INTRO_TEMPLATE = """\
192 This is an automated email from the git hooks/post-receive script.
194 %(pusher)s pushed a change to %(refname_type)s %(short_refname)s
195 in repository %(repo_shortname)s.
200 FOOTER_TEMPLATE = """\
203 To stop receiving notification emails like this one, please contact
208 REWIND_ONLY_TEMPLATE = """\
209 This update removed existing revisions from the reference, leaving the
210 reference pointing at a previous point in the repository history.
212 * -- * -- N %(refname)s (%(newrev_short)s)
214 O -- O -- O (%(oldrev_short)s)
216 Any revisions marked "omits" are not gone; other references still
217 refer to them. Any revisions marked "discards" are gone forever.
221 NON_FF_TEMPLATE = """\
222 This update added new revisions after undoing existing revisions.
223 That is to say, some revisions that were in the old version of the
224 %(refname_type)s are not in the new version. This situation occurs
225 when a user --force pushes a change and generates a repository
226 containing something like this:
228 * -- * -- B -- O -- O -- O (%(oldrev_short)s)
230 N -- N -- N %(refname)s (%(newrev_short)s)
232 You should already have received notification emails for all of the O
233 revisions, and so the following emails describe only the N revisions
234 from the common base, B.
236 Any revisions marked "omits" are not gone; other references still
237 refer to them. Any revisions marked "discards" are gone forever.
241 NO_NEW_REVISIONS_TEMPLATE = """\
242 No new revisions were added by this update.
246 DISCARDED_REVISIONS_TEMPLATE = """\
247 This change permanently discards the following revisions:
251 NO_DISCARDED_REVISIONS_TEMPLATE = """\
252 The revisions that were on this %(refname_type)s are still contained in
253 other references; therefore, this change does not discard any commits
258 NEW_REVISIONS_TEMPLATE = """\
259 The %(tot)s revisions listed above as "new" are entirely new to this
260 repository and will be described in separate emails. The revisions
261 listed as "adds" were already present in the repository and have only
262 been added to this reference.
267 TAG_CREATED_TEMPLATE = """\
268 at %(newrev_short)-9s (%(newrev_type)s)
272 TAG_UPDATED_TEMPLATE = """\
273 *** WARNING: tag %(short_refname)s was modified! ***
275 from %(oldrev_short)-9s (%(oldrev_type)s)
276 to %(newrev_short)-9s (%(newrev_type)s)
280 TAG_DELETED_TEMPLATE = """\
281 *** WARNING: tag %(short_refname)s was deleted! ***
286 # The template used in summary tables. It looks best if this uses the
287 # same alignment as TAG_CREATED_TEMPLATE and TAG_UPDATED_TEMPLATE.
288 BRIEF_SUMMARY_TEMPLATE = """\
289 %(action)10s %(rev_short)-9s %(text)s
293 NON_COMMIT_UPDATE_TEMPLATE = """\
294 This is an unusual reference change because the reference did not
295 refer to a commit either before or after the change. We do not know
296 how to provide full information about this reference change.
300 REVISION_HEADER_TEMPLATE = """\
303 Cc: %(cc_recipients)s
304 Subject: %(emailprefix)s%(num)02d/%(tot)02d: %(oneline)s
306 Content-Type: text/%(contenttype)s; charset=%(charset)s
307 Content-Transfer-Encoding: 8bit
309 Reply-To: %(reply_to)s
310 In-Reply-To: %(reply_to_msgid)s
311 References: %(reply_to_msgid)s
313 X-Git-Repo: %(repo_shortname)s
314 X-Git-Refname: %(refname)s
315 X-Git-Reftype: %(refname_type)s
317 X-Git-NotificationType: diff
318 X-Git-Multimail-Version: %(multimail_version)s
319 Auto-Submitted: auto-generated
322 REVISION_INTRO_TEMPLATE = """\
323 This is an automated email from the git hooks/post-receive script.
325 %(pusher)s pushed a commit to %(refname_type)s %(short_refname)s
326 in repository %(repo_shortname)s.
330 LINK_TEXT_TEMPLATE = """\
331 View the commit online:
336 LINK_HTML_TEMPLATE = """\
337 <p><a href="%(browse_url)s">View the commit online</a>.</p>
341 REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE
344 # Combined, meaning refchange+revision email (for single-commit additions)
345 COMBINED_HEADER_TEMPLATE = """\
350 Content-Type: text/%(contenttype)s; charset=%(charset)s
351 Content-Transfer-Encoding: 8bit
352 Message-ID: %(msgid)s
354 Reply-To: %(reply_to)s
356 X-Git-Repo: %(repo_shortname)s
357 X-Git-Refname: %(refname)s
358 X-Git-Reftype: %(refname_type)s
359 X-Git-Oldrev: %(oldrev)s
360 X-Git-Newrev: %(newrev)s
362 X-Git-NotificationType: ref_changed_plus_diff
363 X-Git-Multimail-Version: %(multimail_version)s
364 Auto-Submitted: auto-generated
367 COMBINED_INTRO_TEMPLATE = """\
368 This is an automated email from the git hooks/post-receive script.
370 %(pusher)s pushed a commit to %(refname_type)s %(short_refname)s
371 in repository %(repo_shortname)s.
375 COMBINED_FOOTER_TEMPLATE = FOOTER_TEMPLATE
378 class CommandError(Exception):
379 def __init__(self, cmd, retcode):
381 self.retcode = retcode
384 'Command "%s" failed with retcode %s' % (' '.join(cmd), retcode,)
388 class ConfigurationException(Exception):
392 # The "git" program (this could be changed to include a full path):
393 GIT_EXECUTABLE = 'git'
396 # How "git" should be invoked (including global arguments), as a list
397 # of words. This variable is usually initialized automatically by
398 # read_git_output() via choose_git_command(), but if a value is set
399 # here then it will be used unconditionally.
403 def choose_git_command():
404 """Decide how to invoke git, and record the choice in GIT_CMD."""
410 # Check to see whether the "-c" option is accepted (it was
411 # only added in Git 1.7.2). We don't actually use the
412 # output of "git --version", though if we needed more
413 # specific version information this would be the place to
415 cmd = [GIT_EXECUTABLE, '-c', 'foo.bar=baz', '--version']
417 GIT_CMD = [GIT_EXECUTABLE, '-c', 'i18n.logoutputencoding=%s' % (ENCODING,)]
419 GIT_CMD = [GIT_EXECUTABLE]
422 def read_git_output(args, input=None, keepends=False, **kw):
423 """Read the output of a Git command."""
428 return read_output(GIT_CMD + args, input=input, keepends=keepends, **kw)
431 def read_output(cmd, input=None, keepends=False, **kw):
433 stdin = subprocess.PIPE
434 input = str_to_bytes(input)
437 p = subprocess.Popen(
438 cmd, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw
440 (out, err) = p.communicate(input)
441 out = bytes_to_str(out)
444 raise CommandError(cmd, retcode)
446 out = out.rstrip('\n\r')
450 def read_git_lines(args, keepends=False, **kw):
451 """Return the lines output by Git command.
453 Return as single lines, with newlines stripped off."""
455 return read_git_output(args, keepends=True, **kw).splitlines(keepends)
458 def git_rev_list_ish(cmd, spec, args=None, **kw):
459 """Common functionality for invoking a 'git rev-list'-like command.
462 * cmd is the Git command to run, e.g., 'rev-list' or 'log'.
463 * spec is a list of revision arguments to pass to the named
464 command. If None, this function returns an empty list.
465 * args is a list of extra arguments passed to the named command.
466 * All other keyword arguments (if any) are passed to the
467 underlying read_git_lines() function.
469 Return the output of the Git command in the form of a list, one
470 entry per output line.
476 args = [cmd, '--stdin'] + args
477 spec_stdin = ''.join(s + '\n' for s in spec)
478 return read_git_lines(args, input=spec_stdin, **kw)
481 def git_rev_list(spec, **kw):
482 """Run 'git rev-list' with the given list of revision arguments.
484 See git_rev_list_ish() for parameter and return value
487 return git_rev_list_ish('rev-list', spec, **kw)
490 def git_log(spec, **kw):
491 """Run 'git log' with the given list of revision arguments.
493 See git_rev_list_ish() for parameter and return value
496 return git_rev_list_ish('log', spec, **kw)
499 def header_encode(text, header_name=None):
500 """Encode and line-wrap the value of an email header field."""
502 # Convert to unicode, if required.
503 if not isinstance(text, unicode):
504 text = unicode(text, 'utf-8')
511 return Header(text, header_name=header_name, charset=Charset(charset)).encode()
514 def addr_header_encode(text, header_name=None):
515 """Encode and line-wrap the value of an email header field containing
518 # Convert to unicode, if required.
519 if not isinstance(text, unicode):
520 text = unicode(text, 'utf-8')
523 formataddr((header_encode(name), emailaddr))
524 for name, emailaddr in getaddresses([text])
532 return Header(text, header_name=header_name, charset=Charset(charset)).encode()
535 class Config(object):
536 def __init__(self, section, git_config=None):
537 """Represent a section of the git configuration.
539 If git_config is specified, it is passed to "git config" in
540 the GIT_CONFIG environment variable, meaning that "git config"
541 will read the specified path rather than the Git default
544 self.section = section
546 self.env = os.environ.copy()
547 self.env['GIT_CONFIG'] = git_config
553 """Split NUL-terminated values."""
555 words = s.split('\0')
556 assert words[-1] == ''
560 def add_config_parameters(c):
561 """Add configuration parameters to Git.
563 c is either an str or a list of str, each element being of the
564 form 'var=val' or 'var', with the same syntax and meaning as
565 the argument of 'git -c var=val'.
567 if isinstance(c, str):
569 parameters = os.environ.get('GIT_CONFIG_PARAMETERS', '')
572 # git expects GIT_CONFIG_PARAMETERS to be of the form
573 # "'name1=value1' 'name2=value2' 'name3=value3'"
574 # including everything inside the double quotes (but not the double
575 # quotes themselves). Spacing is critical. Also, if a value contains
576 # a literal single quote that quote must be represented using the
577 # four character sequence: '\''
578 parameters += ' '.join("'" + x.replace("'", "'\\''") + "'" for x in c)
579 os.environ['GIT_CONFIG_PARAMETERS'] = parameters
581 def get(self, name, default=None):
583 values = self._split(read_git_output(
584 ['config', '--get', '--null', '%s.%s' % (self.section, name)],
585 env=self.env, keepends=True,
587 assert len(values) == 1
592 def get_bool(self, name, default=None):
594 value = read_git_output(
595 ['config', '--get', '--bool', '%s.%s' % (self.section, name)],
600 return value == 'true'
602 def get_all(self, name, default=None):
603 """Read a (possibly multivalued) setting from the configuration.
605 Return the result as a list of values, or default if the name
609 return self._split(read_git_output(
610 ['config', '--get-all', '--null', '%s.%s' % (self.section, name)],
611 env=self.env, keepends=True,
614 t, e, traceback = sys.exc_info()
616 # "the section or key is invalid"; i.e., there is no
617 # value for the specified key.
622 def set(self, name, value):
624 ['config', '%s.%s' % (self.section, name), value],
628 def add(self, name, value):
630 ['config', '--add', '%s.%s' % (self.section, name), value],
634 def __contains__(self, name):
635 return self.get_all(name, default=None) is not None
637 # We don't use this method anymore internally, but keep it here in
638 # case somebody is calling it from their own code:
639 def has_key(self, name):
642 def unset_all(self, name):
645 ['config', '--unset-all', '%s.%s' % (self.section, name)],
649 t, e, traceback = sys.exc_info()
651 # The name doesn't exist, which is what we wanted anyway...
656 def set_recipients(self, name, value):
658 for pair in getaddresses([value]):
659 self.add(name, formataddr(pair))
662 def generate_summaries(*log_args):
663 """Generate a brief summary for each revision requested.
665 log_args are strings that will be passed directly to "git log" as
666 revision selectors. Iterate over (sha1_short, subject) for each
667 commit specified by log_args (subject is the first line of the
668 commit message as a string without EOLs)."""
671 'log', '--abbrev', '--format=%h %s',
672 ] + list(log_args) + ['--']
673 for line in read_git_lines(cmd):
674 yield tuple(line.split(' ', 1))
677 def limit_lines(lines, max_lines):
678 for (index, line) in enumerate(lines):
679 if index < max_lines:
682 if index >= max_lines:
683 yield '... %d lines suppressed ...\n' % (index + 1 - max_lines,)
686 def limit_linelength(lines, max_linelength):
688 # Don't forget that lines always include a trailing newline.
689 if len(line) > max_linelength + 1:
690 line = line[:max_linelength - 7] + ' [...]\n'
694 class CommitSet(object):
695 """A (constant) set of object names.
697 The set should be initialized with full SHA1 object names. The
698 __contains__() method returns True iff its argument is an
699 abbreviation of any the names in the set."""
701 def __init__(self, names):
702 self._names = sorted(names)
705 return len(self._names)
707 def __contains__(self, sha1_abbrev):
708 """Return True iff this set contains sha1_abbrev (which might be abbreviated)."""
710 i = bisect.bisect_left(self._names, sha1_abbrev)
711 return i < len(self) and self._names[i].startswith(sha1_abbrev)
714 class GitObject(object):
715 def __init__(self, sha1, type=None):
717 self.sha1 = self.type = self.commit_sha1 = None
720 self.type = type or read_git_output(['cat-file', '-t', self.sha1])
722 if self.type == 'commit':
723 self.commit_sha1 = self.sha1
724 elif self.type == 'tag':
726 self.commit_sha1 = read_git_output(
727 ['rev-parse', '--verify', '%s^0' % (self.sha1,)]
730 # Cannot deref tag to determine commit_sha1
731 self.commit_sha1 = None
733 self.commit_sha1 = None
735 self.short = read_git_output(['rev-parse', '--short', sha1])
737 def get_summary(self):
738 """Return (sha1_short, subject) for this commit."""
741 raise ValueError('Empty commit has no summary')
743 return next(iter(generate_summaries('--no-walk', self.sha1)))
745 def __eq__(self, other):
746 return isinstance(other, GitObject) and self.sha1 == other.sha1
749 return hash(self.sha1)
751 def __nonzero__(self):
752 return bool(self.sha1)
755 """Python 2 backward compatibility"""
756 return self.__nonzero__()
759 return self.sha1 or ZEROS
762 class Change(object):
763 """A Change that has been made to the Git repository.
765 Abstract class from which both Revisions and ReferenceChanges are
766 derived. A Change knows how to generate a notification email
767 describing itself."""
769 def __init__(self, environment):
770 self.environment = environment
772 self._contains_html_diff = False
774 def _contains_diff(self):
775 # We do contain a diff, should it be rendered in HTML?
776 if self.environment.commit_email_format == "html":
777 self._contains_html_diff = True
779 def _compute_values(self):
780 """Return a dictionary {keyword: expansion} for this Change.
782 Derived classes overload this method to add more entries to
783 the return value. This method is used internally by
784 get_values(). The return value should always be a new
787 values = self.environment.get_values()
788 fromaddr = self.environment.get_fromaddr(change=self)
789 if fromaddr is not None:
790 values['fromaddr'] = fromaddr
791 values['multimail_version'] = get_version()
794 # Aliases usable in template strings. Tuple of pairs (destination,
800 def get_values(self, **extra_values):
801 """Return a dictionary {keyword: expansion} for this Change.
803 Return a dictionary mapping keywords to the values that they
804 should be expanded to for this Change (used when interpolating
805 template strings). If any keyword arguments are supplied, add
806 those to the return value as well. The return value is always
809 if self._values is None:
810 self._values = self._compute_values()
812 values = self._values.copy()
814 values.update(extra_values)
816 for alias, val in self.VALUES_ALIAS:
817 values[alias] = values[val]
820 def expand(self, template, **extra_values):
823 Expand the template (which should be a string) using string
824 interpolation of the values for this Change. If any keyword
825 arguments are provided, also include those in the keywords
826 available for interpolation."""
828 return template % self.get_values(**extra_values)
830 def expand_lines(self, template, html_escape_val=False, **extra_values):
831 """Break template into lines and expand each line."""
833 values = self.get_values(**extra_values)
836 if is_string(values[k]):
837 values[k] = cgi.escape(values[k], True)
838 for line in template.splitlines(True):
841 def expand_header_lines(self, template, **extra_values):
842 """Break template into lines and expand each line as an RFC 2822 header.
844 Encode values and split up lines that are too long. Silently
845 skip lines that contain references to unknown variables."""
847 values = self.get_values(**extra_values)
848 if self._contains_html_diff:
849 self._content_type = 'html'
851 self._content_type = 'plain'
852 values['contenttype'] = self._content_type
854 for line in template.splitlines():
855 (name, value) = line.split(': ', 1)
858 value = value % values
860 t, e, traceback = sys.exc_info()
862 self.environment.log_warning(
863 'Warning: unknown variable %r in the following line; line skipped:\n'
868 if name.lower() in ADDR_HEADERS:
869 value = addr_header_encode(value, name)
871 value = header_encode(value, name)
872 for splitline in ('%s: %s\n' % (name, value)).splitlines(True):
875 def generate_email_header(self):
876 """Generate the RFC 2822 email headers for this Change, a line at a time.
878 The output should not include the trailing blank line."""
880 raise NotImplementedError()
882 def generate_browse_link(self, base_url):
883 """Generate a link to an online repository browser."""
886 def generate_email_intro(self, html_escape_val=False):
887 """Generate the email intro for this Change, a line at a time.
889 The output will be used as the standard boilerplate at the top
890 of the email body."""
892 raise NotImplementedError()
894 def generate_email_body(self):
895 """Generate the main part of the email body, a line at a time.
897 The text in the body might be truncated after a specified
898 number of lines (see multimailhook.emailmaxlines)."""
900 raise NotImplementedError()
902 def generate_email_footer(self, html_escape_val):
903 """Generate the footer of the email, a line at a time.
905 The footer is always included, irrespective of
906 multimailhook.emailmaxlines."""
908 raise NotImplementedError()
910 def _wrap_for_html(self, lines):
911 """Wrap the lines in HTML <pre> tag when using HTML format.
913 Escape special HTML characters and add <pre> and </pre> tags around
914 the given lines if we should be generating HTML as indicated by
915 self._contains_html_diff being set to true.
917 if self._contains_html_diff:
918 yield "<pre style='margin:0'>\n"
921 yield cgi.escape(line)
928 def generate_email(self, push, body_filter=None, extra_header_values={}):
929 """Generate an email describing this change.
931 Iterate over the lines (including the header lines) of an
932 email describing this change. If body_filter is not None,
933 then use it to filter the lines that are intended for the
936 The extra_header_values field is received as a dict and not as
937 **kwargs, to allow passing other keyword arguments in the
938 future (e.g. passing extra values to generate_email_intro()"""
940 for line in self.generate_email_header(**extra_header_values):
943 html_escape_val = (self.environment.html_in_intro and
944 self._contains_html_diff)
945 intro = self.generate_email_intro(html_escape_val)
946 if not self.environment.html_in_intro:
947 intro = self._wrap_for_html(intro)
951 if self.environment.commitBrowseURL:
952 for line in self.generate_browse_link(self.environment.commitBrowseURL):
955 body = self.generate_email_body(push)
956 if body_filter is not None:
957 body = body_filter(body)
960 if self._contains_html_diff:
961 # "white-space: pre" is the default, but we need to
962 # specify it again in case the message is viewed in a
963 # webmail which wraps it in an element setting white-space
964 # to something else (Zimbra does this and sets
965 # white-space: pre-line).
966 yield '<pre style="white-space: pre; background: #F8F8F8">'
968 if self._contains_html_diff:
969 # This is very, very naive. It would be much better to really
970 # parse the diff, i.e. look at how many lines do we have in
971 # the hunk headers instead of blindly highlighting everything
972 # that looks like it might be part of a diff.
975 if line.startswith('--- a/'):
978 elif line.startswith('diff ') or line.startswith('index '):
982 if line.startswith('+++ '):
984 elif line.startswith('@@'):
986 elif line.startswith('+'):
988 elif line.startswith('-'):
990 elif line.startswith('commit '):
992 elif line.startswith(' '):
995 # Chop the trailing LF, we don't want it inside <pre>.
996 line = cgi.escape(line[:-1])
998 if bgcolor or fgcolor:
999 style = 'display:block; white-space:pre;'
1001 style += 'background:#' + bgcolor + ';'
1003 style += 'color:#' + fgcolor + ';'
1004 # Use a <span style='display:block> to color the
1005 # whole line. The newline must be inside the span
1006 # to display properly both in Firefox and in
1007 # text-based browser.
1008 line = "<span style='%s'>%s\n</span>" % (style, line)
1013 if self._contains_html_diff:
1015 html_escape_val = (self.environment.html_in_footer and
1016 self._contains_html_diff)
1017 footer = self.generate_email_footer(html_escape_val)
1018 if not self.environment.html_in_footer:
1019 footer = self._wrap_for_html(footer)
1023 def get_alt_fromaddr(self):
1027 class Revision(Change):
1028 """A Change consisting of a single git commit."""
1030 CC_RE = re.compile(r'^\s*C[Cc]:\s*(?P<to>[^#]+@[^\s#]*)\s*(#.*)?$')
1032 def __init__(self, reference_change, rev, num, tot):
1033 Change.__init__(self, reference_change.environment)
1034 self.reference_change = reference_change
1036 self.change_type = self.reference_change.change_type
1037 self.refname = self.reference_change.refname
1040 self.author = read_git_output(['log', '--no-walk', '--format=%aN <%aE>', self.rev.sha1])
1041 self.recipients = self.environment.get_revision_recipients(self)
1043 self.cc_recipients = ''
1044 if self.environment.get_scancommitforcc():
1045 self.cc_recipients = ', '.join(to.strip() for to in self._cc_recipients())
1046 if self.cc_recipients:
1047 self.environment.log_msg(
1048 'Add %s to CC for %s\n' % (self.cc_recipients, self.rev.sha1))
1050 def _cc_recipients(self):
1052 message = read_git_output(['log', '--no-walk', '--format=%b', self.rev.sha1])
1053 lines = message.strip().split('\n')
1055 m = re.match(self.CC_RE, line)
1057 cc_recipients.append(m.group('to'))
1059 return cc_recipients
1061 def _compute_values(self):
1062 values = Change._compute_values(self)
1064 oneline = read_git_output(
1065 ['log', '--format=%s', '--no-walk', self.rev.sha1]
1068 values['rev'] = self.rev.sha1
1069 values['rev_short'] = self.rev.short
1070 values['change_type'] = self.change_type
1071 values['refname'] = self.refname
1072 values['newrev'] = self.rev.sha1
1073 values['short_refname'] = self.reference_change.short_refname
1074 values['refname_type'] = self.reference_change.refname_type
1075 values['reply_to_msgid'] = self.reference_change.msgid
1076 values['num'] = self.num
1077 values['tot'] = self.tot
1078 values['recipients'] = self.recipients
1079 if self.cc_recipients:
1080 values['cc_recipients'] = self.cc_recipients
1081 values['oneline'] = oneline
1082 values['author'] = self.author
1084 reply_to = self.environment.get_reply_to_commit(self)
1086 values['reply_to'] = reply_to
1090 def generate_email_header(self, **extra_values):
1091 for line in self.expand_header_lines(
1092 REVISION_HEADER_TEMPLATE, **extra_values
1096 def generate_browse_link(self, base_url):
1097 if '%(' not in base_url:
1098 base_url += '%(id)s'
1099 url = "".join(self.expand_lines(base_url))
1100 if self._content_type == 'html':
1101 for line in self.expand_lines(LINK_HTML_TEMPLATE,
1102 html_escape_val=True,
1105 elif self._content_type == 'plain':
1106 for line in self.expand_lines(LINK_TEXT_TEMPLATE,
1107 html_escape_val=False,
1111 raise NotImplementedError("Content-type %s unsupported. Please report it as a bug.")
1113 def generate_email_intro(self, html_escape_val=False):
1114 for line in self.expand_lines(REVISION_INTRO_TEMPLATE,
1115 html_escape_val=html_escape_val):
1118 def generate_email_body(self, push):
1119 """Show this revision."""
1121 for line in read_git_lines(
1122 ['log'] + self.environment.commitlogopts + ['-1', self.rev.sha1],
1125 if line.startswith('Date: ') and self.environment.date_substitute:
1126 yield self.environment.date_substitute + line[len('Date: '):]
1130 def generate_email_footer(self, html_escape_val):
1131 return self.expand_lines(REVISION_FOOTER_TEMPLATE,
1132 html_escape_val=html_escape_val)
1134 def generate_email(self, push, body_filter=None, extra_header_values={}):
1135 self._contains_diff()
1136 return Change.generate_email(self, push, body_filter, extra_header_values)
1138 def get_alt_fromaddr(self):
1139 return self.environment.from_commit
1142 class ReferenceChange(Change):
1143 """A Change to a Git reference.
1145 An abstract class representing a create, update, or delete of a
1146 Git reference. Derived classes handle specific types of reference
1147 (e.g., tags vs. branches). These classes generate the main
1148 reference change email summarizing the reference change and
1149 whether it caused any any commits to be added or removed.
1151 ReferenceChange objects are usually created using the static
1152 create() method, which has the logic to decide which derived class
1155 REF_RE = re.compile(r'^refs\/(?P<area>[^\/]+)\/(?P<shortname>.*)$')
1158 def create(environment, oldrev, newrev, refname):
1159 """Return a ReferenceChange object representing the change.
1161 Return an object that represents the type of change that is being
1162 made. oldrev and newrev should be SHA1s or ZEROS."""
1164 old = GitObject(oldrev)
1165 new = GitObject(newrev)
1168 # The revision type tells us what type the commit is, combined with
1169 # the location of the ref we can decide between
1174 m = ReferenceChange.REF_RE.match(refname)
1176 area = m.group('area')
1177 short_refname = m.group('shortname')
1180 short_refname = refname
1182 if rev.type == 'tag':
1184 klass = AnnotatedTagChange
1185 elif rev.type == 'commit':
1187 # Non-annotated tag:
1188 klass = NonAnnotatedTagChange
1189 elif area == 'heads':
1191 klass = BranchChange
1192 elif area == 'remotes':
1194 environment.log_warning(
1195 '*** Push-update of tracking branch %r\n'
1196 '*** - incomplete email generated.\n'
1199 klass = OtherReferenceChange
1201 # Some other reference namespace:
1202 environment.log_warning(
1203 '*** Push-update of strange reference %r\n'
1204 '*** - incomplete email generated.\n'
1207 klass = OtherReferenceChange
1209 # Anything else (is there anything else?)
1210 environment.log_warning(
1211 '*** Unknown type of update to %r (%s)\n'
1212 '*** - incomplete email generated.\n'
1213 % (refname, rev.type,)
1215 klass = OtherReferenceChange
1219 refname=refname, short_refname=short_refname,
1220 old=old, new=new, rev=rev,
1223 def __init__(self, environment, refname, short_refname, old, new, rev):
1224 Change.__init__(self, environment)
1225 self.change_type = {
1226 (False, True): 'create',
1227 (True, True): 'update',
1228 (True, False): 'delete',
1229 }[bool(old), bool(new)]
1230 self.refname = refname
1231 self.short_refname = short_refname
1235 self.msgid = make_msgid()
1236 self.diffopts = environment.diffopts
1237 self.graphopts = environment.graphopts
1238 self.logopts = environment.logopts
1239 self.commitlogopts = environment.commitlogopts
1240 self.showgraph = environment.refchange_showgraph
1241 self.showlog = environment.refchange_showlog
1243 self.header_template = REFCHANGE_HEADER_TEMPLATE
1244 self.intro_template = REFCHANGE_INTRO_TEMPLATE
1245 self.footer_template = FOOTER_TEMPLATE
1247 def _compute_values(self):
1248 values = Change._compute_values(self)
1250 values['change_type'] = self.change_type
1251 values['refname_type'] = self.refname_type
1252 values['refname'] = self.refname
1253 values['short_refname'] = self.short_refname
1254 values['msgid'] = self.msgid
1255 values['recipients'] = self.recipients
1256 values['oldrev'] = str(self.old)
1257 values['oldrev_short'] = self.old.short
1258 values['newrev'] = str(self.new)
1259 values['newrev_short'] = self.new.short
1262 values['oldrev_type'] = self.old.type
1264 values['newrev_type'] = self.new.type
1266 reply_to = self.environment.get_reply_to_refchange(self)
1268 values['reply_to'] = reply_to
1272 def send_single_combined_email(self, known_added_sha1s):
1273 """Determine if a combined refchange/revision email should be sent
1275 If there is only a single new (non-merge) commit added by a
1276 change, it is useful to combine the ReferenceChange and
1277 Revision emails into one. In such a case, return the single
1278 revision; otherwise, return None.
1280 This method is overridden in BranchChange."""
1284 def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):
1285 """Generate an email describing this change AND specified revision.
1287 Iterate over the lines (including the header lines) of an
1288 email describing this change. If body_filter is not None,
1289 then use it to filter the lines that are intended for the
1292 The extra_header_values field is received as a dict and not as
1293 **kwargs, to allow passing other keyword arguments in the
1294 future (e.g. passing extra values to generate_email_intro()
1296 This method is overridden in BranchChange."""
1298 raise NotImplementedError
1300 def get_subject(self):
1302 'create': REF_CREATED_SUBJECT_TEMPLATE,
1303 'update': REF_UPDATED_SUBJECT_TEMPLATE,
1304 'delete': REF_DELETED_SUBJECT_TEMPLATE,
1306 return self.expand(template)
1308 def generate_email_header(self, **extra_values):
1309 if 'subject' not in extra_values:
1310 extra_values['subject'] = self.get_subject()
1312 for line in self.expand_header_lines(
1313 self.header_template, **extra_values
1317 def generate_email_intro(self, html_escape_val=False):
1318 for line in self.expand_lines(self.intro_template,
1319 html_escape_val=html_escape_val):
1322 def generate_email_body(self, push):
1323 """Call the appropriate body-generation routine.
1325 Call one of generate_create_summary() /
1326 generate_update_summary() / generate_delete_summary()."""
1329 'create': self.generate_create_summary,
1330 'delete': self.generate_delete_summary,
1331 'update': self.generate_update_summary,
1332 }[self.change_type](push)
1333 for line in change_summary:
1336 for line in self.generate_revision_change_summary(push):
1339 def generate_email_footer(self, html_escape_val):
1340 return self.expand_lines(self.footer_template,
1341 html_escape_val=html_escape_val)
1343 def generate_revision_change_graph(self, push):
1345 args = ['--graph'] + self.graphopts
1346 for newold in ('new', 'old'):
1348 spec = push.get_commits_spec(newold, self)
1349 for line in git_log(spec, args=args, keepends=True):
1353 yield 'Graph of %s commits:\n\n' % (
1354 {'new': 'new', 'old': 'discarded'}[newold],)
1359 def generate_revision_change_log(self, new_commits_list):
1362 yield 'Detailed log of new commits:\n\n'
1363 for line in read_git_lines(
1364 ['log', '--no-walk'] +
1372 def generate_new_revision_summary(self, tot, new_commits_list, push):
1373 for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot):
1375 for line in self.generate_revision_change_graph(push):
1377 for line in self.generate_revision_change_log(new_commits_list):
1380 def generate_revision_change_summary(self, push):
1381 """Generate a summary of the revisions added/removed by this change."""
1383 if self.new.commit_sha1 and not self.old.commit_sha1:
1384 # A new reference was created. List the new revisions
1385 # brought by the new reference (i.e., those revisions that
1386 # were not in the repository before this reference
1388 sha1s = list(push.get_new_commits(self))
1392 Revision(self, GitObject(sha1), num=i + 1, tot=tot)
1393 for (i, sha1) in enumerate(sha1s)
1397 yield self.expand('This %(refname_type)s includes the following new commits:\n')
1399 for r in new_revisions:
1400 (sha1, subject) = r.rev.get_summary()
1402 BRIEF_SUMMARY_TEMPLATE, action='new', text=subject,
1405 for line in self.generate_new_revision_summary(
1406 tot, [r.rev.sha1 for r in new_revisions], push):
1409 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
1412 elif self.new.commit_sha1 and self.old.commit_sha1:
1413 # A reference was changed to point at a different commit.
1414 # List the revisions that were removed and/or added *from
1415 # that reference* by this reference change, along with a
1416 # diff between the trees for its old and new values.
1418 # List of the revisions that were added to the branch by
1419 # this update. Note this list can include revisions that
1420 # have already had notification emails; we want such
1421 # revisions in the summary even though we will not send
1422 # new notification emails for them.
1423 adds = list(generate_summaries(
1424 '--topo-order', '--reverse', '%s..%s'
1425 % (self.old.commit_sha1, self.new.commit_sha1,)
1428 # List of the revisions that were removed from the branch
1429 # by this update. This will be empty except for
1430 # non-fast-forward updates.
1431 discards = list(generate_summaries(
1432 '%s..%s' % (self.new.commit_sha1, self.old.commit_sha1,)
1436 new_commits_list = push.get_new_commits(self)
1438 new_commits_list = []
1439 new_commits = CommitSet(new_commits_list)
1442 discarded_commits = CommitSet(push.get_discarded_commits(self))
1444 discarded_commits = CommitSet([])
1446 if discards and adds:
1447 for (sha1, subject) in discards:
1448 if sha1 in discarded_commits:
1453 BRIEF_SUMMARY_TEMPLATE, action=action,
1454 rev_short=sha1, text=subject,
1456 for (sha1, subject) in adds:
1457 if sha1 in new_commits:
1462 BRIEF_SUMMARY_TEMPLATE, action=action,
1463 rev_short=sha1, text=subject,
1466 for line in self.expand_lines(NON_FF_TEMPLATE):
1470 for (sha1, subject) in discards:
1471 if sha1 in discarded_commits:
1476 BRIEF_SUMMARY_TEMPLATE, action=action,
1477 rev_short=sha1, text=subject,
1480 for line in self.expand_lines(REWIND_ONLY_TEMPLATE):
1484 (sha1, subject) = self.old.get_summary()
1486 BRIEF_SUMMARY_TEMPLATE, action='from',
1487 rev_short=sha1, text=subject,
1489 for (sha1, subject) in adds:
1490 if sha1 in new_commits:
1495 BRIEF_SUMMARY_TEMPLATE, action=action,
1496 rev_short=sha1, text=subject,
1502 for line in self.generate_new_revision_summary(
1503 len(new_commits), new_commits_list, push):
1506 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
1508 for line in self.generate_revision_change_graph(push):
1511 # The diffstat is shown from the old revision to the new
1512 # revision. This is to show the truth of what happened in
1513 # this change. There's no point showing the stat from the
1514 # base to the new revision because the base is effectively a
1515 # random revision at this point - the user will be interested
1516 # in what this revision changed - including the undoing of
1517 # previous revisions in the case of non-fast-forward updates.
1519 yield 'Summary of changes:\n'
1520 for line in read_git_lines(
1523 ['%s..%s' % (self.old.commit_sha1, self.new.commit_sha1,)],
1528 elif self.old.commit_sha1 and not self.new.commit_sha1:
1529 # A reference was deleted. List the revisions that were
1530 # removed from the repository by this reference change.
1532 sha1s = list(push.get_discarded_commits(self))
1534 discarded_revisions = [
1535 Revision(self, GitObject(sha1), num=i + 1, tot=tot)
1536 for (i, sha1) in enumerate(sha1s)
1539 if discarded_revisions:
1540 for line in self.expand_lines(DISCARDED_REVISIONS_TEMPLATE):
1543 for r in discarded_revisions:
1544 (sha1, subject) = r.rev.get_summary()
1546 BRIEF_SUMMARY_TEMPLATE, action='discards', text=subject,
1548 for line in self.generate_revision_change_graph(push):
1551 for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE):
1554 elif not self.old.commit_sha1 and not self.new.commit_sha1:
1555 for line in self.expand_lines(NON_COMMIT_UPDATE_TEMPLATE):
1558 def generate_create_summary(self, push):
1559 """Called for the creation of a reference."""
1561 # This is a new reference and so oldrev is not valid
1562 (sha1, subject) = self.new.get_summary()
1564 BRIEF_SUMMARY_TEMPLATE, action='at',
1565 rev_short=sha1, text=subject,
1569 def generate_update_summary(self, push):
1570 """Called for the change of a pre-existing branch."""
1574 def generate_delete_summary(self, push):
1575 """Called for the deletion of any type of reference."""
1577 (sha1, subject) = self.old.get_summary()
1579 BRIEF_SUMMARY_TEMPLATE, action='was',
1580 rev_short=sha1, text=subject,
1584 def get_alt_fromaddr(self):
1585 return self.environment.from_refchange
1588 class BranchChange(ReferenceChange):
1589 refname_type = 'branch'
1591 def __init__(self, environment, refname, short_refname, old, new, rev):
1592 ReferenceChange.__init__(
1594 refname=refname, short_refname=short_refname,
1595 old=old, new=new, rev=rev,
1597 self.recipients = environment.get_refchange_recipients(self)
1598 self._single_revision = None
1600 def send_single_combined_email(self, known_added_sha1s):
1601 if not self.environment.combine_when_single_commit:
1604 # In the sadly-all-too-frequent usecase of people pushing only
1605 # one of their commits at a time to a repository, users feel
1606 # the reference change summary emails are noise rather than
1607 # important signal. This is because, in this particular
1608 # usecase, there is a reference change summary email for each
1609 # new commit, and all these summaries do is point out that
1610 # there is one new commit (which can readily be inferred by
1611 # the existence of the individual revision email that is also
1612 # sent). In such cases, our users prefer there to be a combined
1613 # reference change summary/new revision email.
1615 # So, if the change is an update and it doesn't discard any
1616 # commits, and it adds exactly one non-merge commit (gerrit
1617 # forces a workflow where every commit is individually merged
1618 # and the git-multimail hook fired off for just this one
1619 # change), then we send a combined refchange/revision email.
1621 # If this change is a reference update that doesn't discard
1623 if self.change_type != 'update':
1627 ['merge-base', self.old.sha1, self.new.sha1]
1628 ) != [self.old.sha1]:
1631 # Check if this update introduced exactly one non-merge
1634 def split_line(line):
1635 """Split line into (sha1, [parent,...])."""
1637 words = line.split()
1638 return (words[0], words[1:])
1640 # Get the new commits introduced by the push as a list of
1641 # (sha1, [parent,...])
1644 for line in read_git_lines(
1646 'log', '-3', '--format=%H %P',
1647 '%s..%s' % (self.old.sha1, self.new.sha1),
1655 # If the newest commit is a merge, save it for a later check
1656 # but otherwise ignore it
1658 tot = len(new_commits)
1659 if len(new_commits[0][1]) > 1:
1660 merge = new_commits[0][0]
1663 # Our primary check: we can't combine if more than one commit
1664 # is introduced. We also currently only combine if the new
1665 # commit is a non-merge commit, though it may make sense to
1666 # combine if it is a merge as well.
1668 len(new_commits) == 1 and
1669 len(new_commits[0][1]) == 1 and
1670 new_commits[0][0] in known_added_sha1s
1674 # We do not want to combine revision and refchange emails if
1675 # those go to separate locations.
1676 rev = Revision(self, GitObject(new_commits[0][0]), 1, tot)
1677 if rev.recipients != self.recipients:
1680 # We ignored the newest commit if it was just a merge of the one
1681 # commit being introduced. But we don't want to ignore that
1682 # merge commit it it involved conflict resolutions. Check that.
1683 if merge and merge != read_git_output(['diff-tree', '--cc', merge]):
1686 # We can combine the refchange and one new revision emails
1687 # into one. Return the Revision that a combined email should
1690 except CommandError:
1691 # Cannot determine number of commits in old..new or new..old;
1692 # don't combine reference/revision emails:
1695 def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):
1696 values = revision.get_values()
1697 if extra_header_values:
1698 values.update(extra_header_values)
1699 if 'subject' not in extra_header_values:
1700 values['subject'] = self.expand(COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE, **values)
1702 self._single_revision = revision
1703 self._contains_diff()
1704 self.header_template = COMBINED_HEADER_TEMPLATE
1705 self.intro_template = COMBINED_INTRO_TEMPLATE
1706 self.footer_template = COMBINED_FOOTER_TEMPLATE
1708 def revision_gen_link(base_url):
1709 # revision is used only to generate the body, and
1710 # _content_type is set while generating headers. Get it
1711 # from the BranchChange object.
1712 revision._content_type = self._content_type
1713 return revision.generate_browse_link(base_url)
1714 self.generate_browse_link = revision_gen_link
1715 for line in self.generate_email(push, body_filter, values):
1718 def generate_email_body(self, push):
1719 '''Call the appropriate body generation routine.
1721 If this is a combined refchange/revision email, the special logic
1722 for handling this combined email comes from this function. For
1723 other cases, we just use the normal handling.'''
1725 # If self._single_revision isn't set; don't override
1726 if not self._single_revision:
1727 for line in super(BranchChange, self).generate_email_body(push):
1731 # This is a combined refchange/revision email; we first provide
1732 # some info from the refchange portion, and then call the revision
1733 # generate_email_body function to handle the revision portion.
1734 adds = list(generate_summaries(
1735 '--topo-order', '--reverse', '%s..%s'
1736 % (self.old.commit_sha1, self.new.commit_sha1,)
1739 yield self.expand("The following commit(s) were added to %(refname)s by this push:\n")
1740 for (sha1, subject) in adds:
1742 BRIEF_SUMMARY_TEMPLATE, action='new',
1743 rev_short=sha1, text=subject,
1746 yield self._single_revision.rev.short + " is described below\n"
1749 for line in self._single_revision.generate_email_body(push):
1753 class AnnotatedTagChange(ReferenceChange):
1754 refname_type = 'annotated tag'
1756 def __init__(self, environment, refname, short_refname, old, new, rev):
1757 ReferenceChange.__init__(
1759 refname=refname, short_refname=short_refname,
1760 old=old, new=new, rev=rev,
1762 self.recipients = environment.get_announce_recipients(self)
1763 self.show_shortlog = environment.announce_show_shortlog
1765 ANNOTATED_TAG_FORMAT = (
1772 def describe_tag(self, push):
1773 """Describe the new value of an annotated tag."""
1775 # Use git for-each-ref to pull out the individual fields from
1777 [tagobject, tagtype, tagger, tagged] = read_git_lines(
1778 ['for-each-ref', '--format=%s' % (self.ANNOTATED_TAG_FORMAT,), self.refname],
1782 BRIEF_SUMMARY_TEMPLATE, action='tagging',
1783 rev_short=tagobject, text='(%s)' % (tagtype,),
1785 if tagtype == 'commit':
1786 # If the tagged object is a commit, then we assume this is a
1787 # release, and so we calculate which tag this tag is
1790 prevtag = read_git_output(['describe', '--abbrev=0', '%s^' % (self.new,)])
1791 except CommandError:
1794 yield ' replaces %s\n' % (prevtag,)
1797 yield ' length %s bytes\n' % (read_git_output(['cat-file', '-s', tagobject]),)
1799 yield ' tagged by %s\n' % (tagger,)
1800 yield ' on %s\n' % (tagged,)
1803 # Show the content of the tag message; this might contain a
1804 # change log or release notes so is worth displaying.
1806 contents = list(read_git_lines(['cat-file', 'tag', self.new.sha1], keepends=True))
1807 contents = contents[contents.index('\n') + 1:]
1808 if contents and contents[-1][-1:] != '\n':
1809 contents.append('\n')
1810 for line in contents:
1813 if self.show_shortlog and tagtype == 'commit':
1814 # Only commit tags make sense to have rev-list operations
1818 # Show changes since the previous release
1819 revlist = read_git_output(
1820 ['rev-list', '--pretty=short', '%s..%s' % (prevtag, self.new,)],
1824 # No previous tag, show all the changes since time
1826 revlist = read_git_output(
1827 ['rev-list', '--pretty=short', '%s' % (self.new,)],
1830 for line in read_git_lines(['shortlog'], input=revlist, keepends=True):
1836 def generate_create_summary(self, push):
1837 """Called for the creation of an annotated tag."""
1839 for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1842 for line in self.describe_tag(push):
1845 def generate_update_summary(self, push):
1846 """Called for the update of an annotated tag.
1848 This is probably a rare event and may not even be allowed."""
1850 for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1853 for line in self.describe_tag(push):
1856 def generate_delete_summary(self, push):
1857 """Called when a non-annotated reference is updated."""
1859 for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1862 yield self.expand(' tag was %(oldrev_short)s\n')
1866 class NonAnnotatedTagChange(ReferenceChange):
1867 refname_type = 'tag'
1869 def __init__(self, environment, refname, short_refname, old, new, rev):
1870 ReferenceChange.__init__(
1872 refname=refname, short_refname=short_refname,
1873 old=old, new=new, rev=rev,
1875 self.recipients = environment.get_refchange_recipients(self)
1877 def generate_create_summary(self, push):
1878 """Called for the creation of an annotated tag."""
1880 for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1883 def generate_update_summary(self, push):
1884 """Called when a non-annotated reference is updated."""
1886 for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1889 def generate_delete_summary(self, push):
1890 """Called when a non-annotated reference is updated."""
1892 for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1895 for line in ReferenceChange.generate_delete_summary(self, push):
1899 class OtherReferenceChange(ReferenceChange):
1900 refname_type = 'reference'
1902 def __init__(self, environment, refname, short_refname, old, new, rev):
1903 # We use the full refname as short_refname, because otherwise
1904 # the full name of the reference would not be obvious from the
1905 # text of the email.
1906 ReferenceChange.__init__(
1908 refname=refname, short_refname=refname,
1909 old=old, new=new, rev=rev,
1911 self.recipients = environment.get_refchange_recipients(self)
1914 class Mailer(object):
1915 """An object that can send emails."""
1917 def send(self, lines, to_addrs):
1918 """Send an email consisting of lines.
1920 lines must be an iterable over the lines constituting the
1921 header and body of the email. to_addrs is a list of recipient
1922 addresses (can be needed even if lines already contains a
1923 "To:" field). It can be either a string (comma-separated list
1924 of email addresses) or a Python list of individual email
1929 raise NotImplementedError()
1932 class SendMailer(Mailer):
1933 """Send emails using 'sendmail -oi -t'."""
1935 SENDMAIL_CANDIDATES = [
1936 '/usr/sbin/sendmail',
1937 '/usr/lib/sendmail',
1941 def find_sendmail():
1942 for path in SendMailer.SENDMAIL_CANDIDATES:
1943 if os.access(path, os.X_OK):
1946 raise ConfigurationException(
1947 'No sendmail executable found. '
1948 'Try setting multimailhook.sendmailCommand.'
1951 def __init__(self, command=None, envelopesender=None):
1952 """Construct a SendMailer instance.
1954 command should be the command and arguments used to invoke
1955 sendmail, as a list of strings. If an envelopesender is
1956 provided, it will also be passed to the command, via '-f
1960 self.command = command[:]
1962 self.command = [self.find_sendmail(), '-oi', '-t']
1965 self.command.extend(['-f', envelopesender])
1967 def send(self, lines, to_addrs):
1969 p = subprocess.Popen(self.command, stdin=subprocess.PIPE)
1972 '*** Cannot execute command: %s\n' % ' '.join(self.command) +
1973 '*** %s\n' % sys.exc_info()[1] +
1974 '*** Try setting multimailhook.mailer to "smtp"\n' +
1975 '*** to send emails without using the sendmail command.\n'
1979 lines = (str_to_bytes(line) for line in lines)
1980 p.stdin.writelines(lines)
1983 '*** Error while generating commit email\n'
1984 '*** - mail sending aborted.\n'
1987 # subprocess.terminate() is not available in Python 2.4
1989 except AttributeError:
1996 raise CommandError(self.command, retcode)
1999 class SMTPMailer(Mailer):
2000 """Send emails using Python's smtplib."""
2002 def __init__(self, envelopesender, smtpserver,
2003 smtpservertimeout=10.0, smtpserverdebuglevel=0,
2004 smtpencryption='none',
2005 smtpuser='', smtppass='',
2008 if not envelopesender:
2010 'fatal: git_multimail: cannot use SMTPMailer without a sender address.\n'
2011 'please set either multimailhook.envelopeSender or user.email\n'
2014 if smtpencryption == 'ssl' and not (smtpuser and smtppass):
2015 raise ConfigurationException(
2016 'Cannot use SMTPMailer with security option ssl '
2017 'without options username and password.'
2019 self.envelopesender = envelopesender
2020 self.smtpserver = smtpserver
2021 self.smtpservertimeout = smtpservertimeout
2022 self.smtpserverdebuglevel = smtpserverdebuglevel
2023 self.security = smtpencryption
2024 self.username = smtpuser
2025 self.password = smtppass
2026 self.smtpcacerts = smtpcacerts
2028 def call(klass, server, timeout):
2030 return klass(server, timeout=timeout)
2032 # Old Python versions do not have timeout= argument.
2033 return klass(server)
2034 if self.security == 'none':
2035 self.smtp = call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)
2036 elif self.security == 'ssl':
2037 if self.smtpcacerts:
2038 raise smtplib.SMTPException(
2039 "Checking certificate is not supported for ssl, prefer starttls"
2041 self.smtp = call(smtplib.SMTP_SSL, self.smtpserver, timeout=self.smtpservertimeout)
2042 elif self.security == 'tls':
2043 if 'ssl' not in sys.modules:
2045 '*** Your Python version does not have the ssl library installed\n'
2046 '*** smtpEncryption=tls is not available.\n'
2047 '*** Either upgrade Python to 2.6 or later\n'
2048 ' or use git_multimail.py version 1.2.\n')
2049 if ':' not in self.smtpserver:
2050 self.smtpserver += ':587' # default port for TLS
2051 self.smtp = call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)
2052 # start: ehlo + starttls
2055 # self.smtp.starttls()
2056 # with acces to the ssl layer
2058 if not self.smtp.has_extn("starttls"):
2059 raise smtplib.SMTPException("STARTTLS extension not supported by server")
2060 resp, reply = self.smtp.docmd("STARTTLS")
2062 raise smtplib.SMTPException("Wrong answer to the STARTTLS command")
2063 if self.smtpcacerts:
2064 self.smtp.sock = ssl.wrap_socket(
2066 ca_certs=self.smtpcacerts,
2067 cert_reqs=ssl.CERT_REQUIRED
2070 self.smtp.sock = ssl.wrap_socket(
2072 cert_reqs=ssl.CERT_NONE
2075 '*** Warning, the server certificat is not verified (smtp) ***\n'
2076 '*** set the option smtpCACerts ***\n'
2078 if not hasattr(self.smtp.sock, "read"):
2079 # using httplib.FakeSocket with Python 2.5.x or earlier
2080 self.smtp.sock.read = self.smtp.sock.recv
2081 self.smtp.file = smtplib.SSLFakeFile(self.smtp.sock)
2082 self.smtp.helo_resp = None
2083 self.smtp.ehlo_resp = None
2084 self.smtp.esmtp_features = {}
2085 self.smtp.does_esmtp = 0
2086 # end: ehlo + starttls
2089 sys.stdout.write('*** Error: Control reached an invalid option. ***')
2091 if self.smtpserverdebuglevel > 0:
2093 "*** Setting debug on for SMTP server connection (%s) ***\n"
2094 % self.smtpserverdebuglevel)
2095 self.smtp.set_debuglevel(self.smtpserverdebuglevel)
2098 '*** Error establishing SMTP connection to %s ***\n'
2100 sys.stderr.write('*** %s\n' % sys.exc_info()[1])
2104 if hasattr(self, 'smtp'):
2108 def send(self, lines, to_addrs):
2110 if self.username or self.password:
2111 self.smtp.login(self.username, self.password)
2112 msg = ''.join(lines)
2113 # turn comma-separated list into Python list if needed.
2114 if is_string(to_addrs):
2115 to_addrs = [email for (name, email) in getaddresses([to_addrs])]
2116 self.smtp.sendmail(self.envelopesender, to_addrs, msg)
2117 except smtplib.SMTPResponseException:
2118 sys.stderr.write('*** Error sending email ***\n')
2119 err = sys.exc_info()[1]
2120 sys.stderr.write('*** Error %d: %s\n' % (err.smtp_code,
2121 bytes_to_str(err.smtp_error)))
2124 # delete the field before quit() so that in case of
2125 # error, self.smtp is deleted anyway.
2129 sys.stderr.write('*** Error closing the SMTP connection ***\n')
2130 sys.stderr.write('*** Exiting anyway ... ***\n')
2131 sys.stderr.write('*** %s\n' % sys.exc_info()[1])
2135 class OutputMailer(Mailer):
2136 """Write emails to an output stream, bracketed by lines of '=' characters.
2138 This is intended for debugging purposes."""
2140 SEPARATOR = '=' * 75 + '\n'
2142 def __init__(self, f):
2145 def send(self, lines, to_addrs):
2146 write_str(self.f, self.SEPARATOR)
2148 write_str(self.f, line)
2149 write_str(self.f, self.SEPARATOR)
2153 """Determine GIT_DIR.
2155 Determine GIT_DIR either from the GIT_DIR environment variable or
2156 from the working directory, using Git's usual rules."""
2159 return read_git_output(['rev-parse', '--git-dir'])
2160 except CommandError:
2161 sys.stderr.write('fatal: git_multimail: not in a git directory\n')
2165 class Environment(object):
2166 """Describes the environment in which the push is occurring.
2168 An Environment object encapsulates information about the local
2169 environment. For example, it knows how to determine:
2171 * the name of the repository to which the push occurred
2173 * what user did the push
2175 * what users want to be informed about various types of changes.
2177 An Environment object is expected to have the following methods:
2179 get_repo_shortname()
2181 Return a short name for the repository, for display
2186 Return the absolute path to the Git repository.
2190 Return a string that will be prefixed to every email's
2195 Return the username of the person who pushed the changes.
2196 This value is used in the email body to indicate who
2199 get_pusher_email() (may return None)
2201 Return the email address of the person who pushed the
2202 changes. The value should be a single RFC 2822 email
2203 address as a string; e.g., "Joe User <user@example.com>"
2204 if available, otherwise "user@example.com". If set, the
2205 value is used as the Reply-To address for refchange
2206 emails. If it is impossible to determine the pusher's
2207 email, this attribute should be set to None (in which case
2208 no Reply-To header will be output).
2212 Return the address to be used as the 'From' email address
2213 in the email envelope.
2215 get_fromaddr(change=None)
2217 Return the 'From' email address used in the email 'From:'
2218 headers. If the change is known when this function is
2219 called, it is passed in as the 'change' parameter. (May
2220 be a full RFC 2822 email address like 'Joe User
2221 <user@example.com>'.)
2225 Return the name and/or email of the repository
2226 administrator. This value is used in the footer as the
2227 person to whom requests to be removed from the
2228 notification list should be sent. Ideally, it should
2229 include a valid email address.
2231 get_reply_to_refchange()
2232 get_reply_to_commit()
2234 Return the address to use in the email "Reply-To" header,
2235 as a string. These can be an RFC 2822 email address, or
2236 None to omit the "Reply-To" header.
2237 get_reply_to_refchange() is used for refchange emails;
2238 get_reply_to_commit() is used for individual commit
2241 get_ref_filter_regex()
2243 Return a tuple -- a compiled regex, and a boolean indicating
2244 whether the regex picks refs to include (if False, the regex
2245 matches on refs to exclude).
2247 get_default_ref_ignore_regex()
2249 Return a regex that should be ignored for both what emails
2250 to send and when computing what commits are considered new
2251 to the repository. Default is "^refs/notes/".
2253 They should also define the following attributes:
2255 announce_show_shortlog (bool)
2257 True iff announce emails should include a shortlog.
2259 commit_email_format (string)
2261 If "html", generate commit emails in HTML instead of plain text
2264 html_in_intro (bool)
2265 html_in_footer (bool)
2267 When generating HTML emails, the introduction (respectively,
2268 the footer) will be HTML-escaped iff html_in_intro (respectively,
2269 the footer) is true. When false, only the values used to expand
2270 the template are escaped.
2272 refchange_showgraph (bool)
2274 True iff refchanges emails should include a detailed graph.
2276 refchange_showlog (bool)
2278 True iff refchanges emails should include a detailed log.
2280 diffopts (list of strings)
2282 The options that should be passed to 'git diff' for the
2283 summary email. The value should be a list of strings
2284 representing words to be passed to the command.
2286 graphopts (list of strings)
2288 Analogous to diffopts, but contains options passed to
2289 'git log --graph' when generating the detailed graph for
2290 a set of commits (see refchange_showgraph)
2292 logopts (list of strings)
2294 Analogous to diffopts, but contains options passed to
2295 'git log' when generating the detailed log for a set of
2296 commits (see refchange_showlog)
2298 commitlogopts (list of strings)
2300 The options that should be passed to 'git log' for each
2301 commit mail. The value should be a list of strings
2302 representing words to be passed to the command.
2304 date_substitute (string)
2306 String to be used in substitution for 'Date:' at start of
2307 line in the output of 'git log'.
2310 On success do not write to stderr
2313 Write email to stdout rather than emailing. Useful for debugging
2315 combine_when_single_commit (bool)
2317 True if a combined email should be produced when a single
2318 new commit is pushed to a branch, False otherwise.
2320 from_refchange, from_commit (strings)
2322 Addresses to use for the From: field for refchange emails
2323 and commit emails respectively. Set from
2324 multimailhook.fromRefchange and multimailhook.fromCommit
2325 by ConfigEnvironmentMixin.
2329 REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$')
2331 def __init__(self, osenv=None):
2332 self.osenv = osenv or os.environ
2333 self.announce_show_shortlog = False
2334 self.commit_email_format = "text"
2335 self.html_in_intro = False
2336 self.html_in_footer = False
2337 self.commitBrowseURL = None
2338 self.maxcommitemails = 500
2339 self.diffopts = ['--stat', '--summary', '--find-copies-harder']
2340 self.graphopts = ['--oneline', '--decorate']
2342 self.refchange_showgraph = False
2343 self.refchange_showlog = False
2344 self.commitlogopts = ['-C', '--stat', '-p', '--cc']
2345 self.date_substitute = 'AuthorDate: '
2348 self.combine_when_single_commit = True
2350 self.COMPUTED_KEYS = [
2363 def get_repo_shortname(self):
2364 """Use the last part of the repo path, with ".git" stripped off if present."""
2366 basename = os.path.basename(os.path.abspath(self.get_repo_path()))
2367 m = self.REPO_NAME_RE.match(basename)
2369 return m.group('name')
2373 def get_pusher(self):
2374 raise NotImplementedError()
2376 def get_pusher_email(self):
2379 def get_fromaddr(self, change=None):
2380 config = Config('user')
2381 fromname = config.get('name', default='')
2382 fromemail = config.get('email', default='')
2384 return formataddr([fromname, fromemail])
2385 return self.get_sender()
2387 def get_administrator(self):
2388 return 'the administrator of this repository'
2390 def get_emailprefix(self):
2393 def get_repo_path(self):
2394 if read_git_output(['rev-parse', '--is-bare-repository']) == 'true':
2395 path = get_git_dir()
2397 path = read_git_output(['rev-parse', '--show-toplevel'])
2398 return os.path.abspath(path)
2400 def get_charset(self):
2403 def get_values(self):
2404 """Return a dictionary {keyword: expansion} for this Environment.
2406 This method is called by Change._compute_values(). The keys
2407 in the returned dictionary are available to be used in any of
2408 the templates. The dictionary is created by calling
2409 self.get_NAME() for each of the attributes named in
2410 COMPUTED_KEYS and recording those that do not return None.
2411 The return value is always a new dictionary."""
2413 if self._values is None:
2414 values = {'': ''} # %()s expands to the empty string.
2416 for key in self.COMPUTED_KEYS:
2417 value = getattr(self, 'get_%s' % (key,))()
2418 if value is not None:
2421 self._values = values
2423 return self._values.copy()
2425 def get_refchange_recipients(self, refchange):
2426 """Return the recipients for notifications about refchange.
2428 Return the list of email addresses to which notifications
2429 about the specified ReferenceChange should be sent."""
2431 raise NotImplementedError()
2433 def get_announce_recipients(self, annotated_tag_change):
2434 """Return the recipients for notifications about annotated_tag_change.
2436 Return the list of email addresses to which notifications
2437 about the specified AnnotatedTagChange should be sent."""
2439 raise NotImplementedError()
2441 def get_reply_to_refchange(self, refchange):
2442 return self.get_pusher_email()
2444 def get_revision_recipients(self, revision):
2445 """Return the recipients for messages about revision.
2447 Return the list of email addresses to which notifications
2448 about the specified Revision should be sent. This method
2449 could be overridden, for example, to take into account the
2450 contents of the revision when deciding whom to notify about
2451 it. For example, there could be a scheme for users to express
2452 interest in particular files or subdirectories, and only
2453 receive notification emails for revisions that affecting those
2456 raise NotImplementedError()
2458 def get_reply_to_commit(self, revision):
2459 return revision.author
2461 def get_default_ref_ignore_regex(self):
2462 # The commit messages of git notes are essentially meaningless
2463 # and "filenames" in git notes commits are an implementational
2464 # detail that might surprise users at first. As such, we
2465 # would need a completely different method for handling emails
2466 # of git notes in order for them to be of benefit for users,
2467 # which we simply do not have right now.
2468 return "^refs/notes/"
2470 def filter_body(self, lines):
2471 """Filter the lines intended for an email body.
2473 lines is an iterable over the lines that would go into the
2474 email body. Filter it (e.g., limit the number of lines, the
2475 line length, character set, etc.), returning another iterable.
2476 See FilterLinesEnvironmentMixin and MaxlinesEnvironmentMixin
2477 for classes implementing this functionality."""
2481 def log_msg(self, msg):
2482 """Write the string msg on a log file or on stderr.
2484 Sends the text to stderr by default, override to change the behavior."""
2485 write_str(sys.stderr, msg)
2487 def log_warning(self, msg):
2488 """Write the string msg on a log file or on stderr.
2490 Sends the text to stderr by default, override to change the behavior."""
2491 write_str(sys.stderr, msg)
2493 def log_error(self, msg):
2494 """Write the string msg on a log file or on stderr.
2496 Sends the text to stderr by default, override to change the behavior."""
2497 write_str(sys.stderr, msg)
2500 class ConfigEnvironmentMixin(Environment):
2501 """A mixin that sets self.config to its constructor's config argument.
2503 This class's constructor consumes the "config" argument.
2505 Mixins that need to inspect the config should inherit from this
2506 class (1) to make sure that "config" is still in the constructor
2507 arguments with its own constructor runs and/or (2) to be sure that
2508 self.config is set after construction."""
2510 def __init__(self, config, **kw):
2511 super(ConfigEnvironmentMixin, self).__init__(**kw)
2512 self.config = config
2515 class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):
2516 """An Environment that reads most of its information from "git config"."""
2519 def forbid_field_values(name, value, forbidden):
2520 for forbidden_val in forbidden:
2521 if value is not None and value.lower() == forbidden:
2522 raise ConfigurationException(
2523 '"%s" is not an allowed setting for %s' % (value, name)
2526 def __init__(self, config, **kw):
2527 super(ConfigOptionsEnvironmentMixin, self).__init__(
2532 ('announce_show_shortlog', 'announceshortlog'),
2533 ('refchange_showgraph', 'refchangeShowGraph'),
2534 ('refchange_showlog', 'refchangeshowlog'),
2536 ('stdout', 'stdout'),
2538 val = config.get_bool(cfg)
2540 setattr(self, var, val)
2542 commit_email_format = config.get('commitEmailFormat')
2543 if commit_email_format is not None:
2544 if commit_email_format != "html" and commit_email_format != "text":
2546 '*** Unknown value for multimailhook.commitEmailFormat: %s\n' %
2547 commit_email_format +
2548 '*** Expected either "text" or "html". Ignoring.\n'
2551 self.commit_email_format = commit_email_format
2553 html_in_intro = config.get_bool('htmlInIntro')
2554 if html_in_intro is not None:
2555 self.html_in_intro = html_in_intro
2557 html_in_footer = config.get_bool('htmlInFooter')
2558 if html_in_footer is not None:
2559 self.html_in_footer = html_in_footer
2561 self.commitBrowseURL = config.get('commitBrowseURL')
2563 maxcommitemails = config.get('maxcommitemails')
2564 if maxcommitemails is not None:
2566 self.maxcommitemails = int(maxcommitemails)
2569 '*** Malformed value for multimailhook.maxCommitEmails: %s\n'
2571 '*** Expected a number. Ignoring.\n'
2574 diffopts = config.get('diffopts')
2575 if diffopts is not None:
2576 self.diffopts = shlex.split(diffopts)
2578 graphopts = config.get('graphOpts')
2579 if graphopts is not None:
2580 self.graphopts = shlex.split(graphopts)
2582 logopts = config.get('logopts')
2583 if logopts is not None:
2584 self.logopts = shlex.split(logopts)
2586 commitlogopts = config.get('commitlogopts')
2587 if commitlogopts is not None:
2588 self.commitlogopts = shlex.split(commitlogopts)
2590 date_substitute = config.get('dateSubstitute')
2591 if date_substitute == 'none':
2592 self.date_substitute = None
2593 elif date_substitute is not None:
2594 self.date_substitute = date_substitute
2596 reply_to = config.get('replyTo')
2597 self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to)
2598 self.forbid_field_values('replyToRefchange',
2599 self.__reply_to_refchange,
2601 self.__reply_to_commit = config.get('replyToCommit', default=reply_to)
2603 self.from_refchange = config.get('fromRefchange')
2604 self.forbid_field_values('fromRefchange',
2605 self.from_refchange,
2607 self.from_commit = config.get('fromCommit')
2608 self.forbid_field_values('fromCommit',
2612 combine = config.get_bool('combineWhenSingleCommit')
2613 if combine is not None:
2614 self.combine_when_single_commit = combine
2616 def get_administrator(self):
2618 self.config.get('administrator') or
2619 self.get_sender() or
2620 super(ConfigOptionsEnvironmentMixin, self).get_administrator()
2623 def get_repo_shortname(self):
2625 self.config.get('reponame') or
2626 super(ConfigOptionsEnvironmentMixin, self).get_repo_shortname()
2629 def get_emailprefix(self):
2630 emailprefix = self.config.get('emailprefix')
2631 if emailprefix is not None:
2632 emailprefix = emailprefix.strip()
2634 return emailprefix + ' '
2638 return '[%s] ' % (self.get_repo_shortname(),)
2640 def get_sender(self):
2641 return self.config.get('envelopesender')
2643 def process_addr(self, addr, change):
2644 if addr.lower() == 'author':
2645 if hasattr(change, 'author'):
2646 return change.author
2649 elif addr.lower() == 'pusher':
2650 return self.get_pusher_email()
2651 elif addr.lower() == 'none':
2656 def get_fromaddr(self, change=None):
2657 fromaddr = self.config.get('from')
2659 alt_fromaddr = change.get_alt_fromaddr()
2661 fromaddr = alt_fromaddr
2663 fromaddr = self.process_addr(fromaddr, change)
2666 return super(ConfigOptionsEnvironmentMixin, self).get_fromaddr(change)
2668 def get_reply_to_refchange(self, refchange):
2669 if self.__reply_to_refchange is None:
2670 return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_refchange(refchange)
2672 return self.process_addr(self.__reply_to_refchange, refchange)
2674 def get_reply_to_commit(self, revision):
2675 if self.__reply_to_commit is None:
2676 return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_commit(revision)
2678 return self.process_addr(self.__reply_to_commit, revision)
2680 def get_scancommitforcc(self):
2681 return self.config.get('scancommitforcc')
2684 class FilterLinesEnvironmentMixin(Environment):
2685 """Handle encoding and maximum line length of body lines.
2687 emailmaxlinelength (int or None)
2689 The maximum length of any single line in the email body.
2690 Longer lines are truncated at that length with ' [...]'
2695 If this field is set to True, then the email body text is
2696 expected to be UTF-8. Any invalid characters are
2697 converted to U+FFFD, the Unicode replacement character
2698 (encoded as UTF-8, of course).
2702 def __init__(self, strict_utf8=True, emailmaxlinelength=500, **kw):
2703 super(FilterLinesEnvironmentMixin, self).__init__(**kw)
2704 self.__strict_utf8 = strict_utf8
2705 self.__emailmaxlinelength = emailmaxlinelength
2707 def filter_body(self, lines):
2708 lines = super(FilterLinesEnvironmentMixin, self).filter_body(lines)
2709 if self.__strict_utf8:
2711 lines = (line.decode(ENCODING, 'replace') for line in lines)
2712 # Limit the line length in Unicode-space to avoid
2713 # splitting characters:
2714 if self.__emailmaxlinelength:
2715 lines = limit_linelength(lines, self.__emailmaxlinelength)
2717 lines = (line.encode(ENCODING, 'replace') for line in lines)
2718 elif self.__emailmaxlinelength:
2719 lines = limit_linelength(lines, self.__emailmaxlinelength)
2724 class ConfigFilterLinesEnvironmentMixin(
2725 ConfigEnvironmentMixin,
2726 FilterLinesEnvironmentMixin,
2728 """Handle encoding and maximum line length based on config."""
2730 def __init__(self, config, **kw):
2731 strict_utf8 = config.get_bool('emailstrictutf8', default=None)
2732 if strict_utf8 is not None:
2733 kw['strict_utf8'] = strict_utf8
2735 emailmaxlinelength = config.get('emailmaxlinelength')
2736 if emailmaxlinelength is not None:
2737 kw['emailmaxlinelength'] = int(emailmaxlinelength)
2739 super(ConfigFilterLinesEnvironmentMixin, self).__init__(
2744 class MaxlinesEnvironmentMixin(Environment):
2745 """Limit the email body to a specified number of lines."""
2747 def __init__(self, emailmaxlines, **kw):
2748 super(MaxlinesEnvironmentMixin, self).__init__(**kw)
2749 self.__emailmaxlines = emailmaxlines
2751 def filter_body(self, lines):
2752 lines = super(MaxlinesEnvironmentMixin, self).filter_body(lines)
2753 if self.__emailmaxlines:
2754 lines = limit_lines(lines, self.__emailmaxlines)
2758 class ConfigMaxlinesEnvironmentMixin(
2759 ConfigEnvironmentMixin,
2760 MaxlinesEnvironmentMixin,
2762 """Limit the email body to the number of lines specified in config."""
2764 def __init__(self, config, **kw):
2765 emailmaxlines = int(config.get('emailmaxlines', default='0'))
2766 super(ConfigMaxlinesEnvironmentMixin, self).__init__(
2768 emailmaxlines=emailmaxlines,
2773 class FQDNEnvironmentMixin(Environment):
2774 """A mixin that sets the host's FQDN to its constructor argument."""
2776 def __init__(self, fqdn, **kw):
2777 super(FQDNEnvironmentMixin, self).__init__(**kw)
2778 self.COMPUTED_KEYS += ['fqdn']
2782 """Return the fully-qualified domain name for this host.
2784 Return None if it is unavailable or unwanted."""
2789 class ConfigFQDNEnvironmentMixin(
2790 ConfigEnvironmentMixin,
2791 FQDNEnvironmentMixin,
2793 """Read the FQDN from the config."""
2795 def __init__(self, config, **kw):
2796 fqdn = config.get('fqdn')
2797 super(ConfigFQDNEnvironmentMixin, self).__init__(
2804 class ComputeFQDNEnvironmentMixin(FQDNEnvironmentMixin):
2805 """Get the FQDN by calling socket.getfqdn()."""
2807 def __init__(self, **kw):
2808 super(ComputeFQDNEnvironmentMixin, self).__init__(
2809 fqdn=socket.getfqdn(),
2814 class PusherDomainEnvironmentMixin(ConfigEnvironmentMixin):
2815 """Deduce pusher_email from pusher by appending an emaildomain."""
2817 def __init__(self, **kw):
2818 super(PusherDomainEnvironmentMixin, self).__init__(**kw)
2819 self.__emaildomain = self.config.get('emaildomain')
2821 def get_pusher_email(self):
2822 if self.__emaildomain:
2823 # Derive the pusher's full email address in the default way:
2824 return '%s@%s' % (self.get_pusher(), self.__emaildomain)
2826 return super(PusherDomainEnvironmentMixin, self).get_pusher_email()
2829 class StaticRecipientsEnvironmentMixin(Environment):
2830 """Set recipients statically based on constructor parameters."""
2834 refchange_recipients, announce_recipients, revision_recipients, scancommitforcc,
2837 super(StaticRecipientsEnvironmentMixin, self).__init__(**kw)
2839 # The recipients for various types of notification emails, as
2840 # RFC 2822 email addresses separated by commas (or the empty
2841 # string if no recipients are configured). Although there is
2842 # a mechanism to choose the recipient lists based on on the
2843 # actual *contents* of the change being reported, we only
2844 # choose based on the *type* of the change. Therefore we can
2845 # compute them once and for all:
2846 if not (refchange_recipients or
2847 announce_recipients or
2848 revision_recipients or
2850 raise ConfigurationException('No email recipients configured!')
2851 self.__refchange_recipients = refchange_recipients
2852 self.__announce_recipients = announce_recipients
2853 self.__revision_recipients = revision_recipients
2855 def get_refchange_recipients(self, refchange):
2856 return self.__refchange_recipients
2858 def get_announce_recipients(self, annotated_tag_change):
2859 return self.__announce_recipients
2861 def get_revision_recipients(self, revision):
2862 return self.__revision_recipients
2865 class ConfigRecipientsEnvironmentMixin(
2866 ConfigEnvironmentMixin,
2867 StaticRecipientsEnvironmentMixin
2869 """Determine recipients statically based on config."""
2871 def __init__(self, config, **kw):
2872 super(ConfigRecipientsEnvironmentMixin, self).__init__(
2874 refchange_recipients=self._get_recipients(
2875 config, 'refchangelist', 'mailinglist',
2877 announce_recipients=self._get_recipients(
2878 config, 'announcelist', 'refchangelist', 'mailinglist',
2880 revision_recipients=self._get_recipients(
2881 config, 'commitlist', 'mailinglist',
2883 scancommitforcc=config.get('scancommitforcc'),
2887 def _get_recipients(self, config, *names):
2888 """Return the recipients for a particular type of message.
2890 Return the list of email addresses to which a particular type
2891 of notification email should be sent, by looking at the config
2892 value for "multimailhook.$name" for each of names. Use the
2893 value from the first name that is configured. The return
2894 value is a (possibly empty) string containing RFC 2822 email
2895 addresses separated by commas. If no configuration could be
2896 found, raise a ConfigurationException."""
2899 lines = config.get_all(name)
2900 if lines is not None:
2901 lines = [line.strip() for line in lines]
2902 # Single "none" is a special value equivalen to empty string.
2903 if lines == ['none']:
2905 return ', '.join(lines)
2910 class StaticRefFilterEnvironmentMixin(Environment):
2911 """Set branch filter statically based on constructor parameters."""
2913 def __init__(self, ref_filter_incl_regex, ref_filter_excl_regex,
2914 ref_filter_do_send_regex, ref_filter_dont_send_regex,
2916 super(StaticRefFilterEnvironmentMixin, self).__init__(**kw)
2918 if ref_filter_incl_regex and ref_filter_excl_regex:
2919 raise ConfigurationException(
2920 "Cannot specify both a ref inclusion and exclusion regex.")
2921 self.__is_inclusion_filter = bool(ref_filter_incl_regex)
2922 default_exclude = self.get_default_ref_ignore_regex()
2923 if ref_filter_incl_regex:
2924 ref_filter_regex = ref_filter_incl_regex
2925 elif ref_filter_excl_regex:
2926 ref_filter_regex = ref_filter_excl_regex + '|' + default_exclude
2928 ref_filter_regex = default_exclude
2930 self.__compiled_regex = re.compile(ref_filter_regex)
2932 raise ConfigurationException(
2933 'Invalid Ref Filter Regex "%s": %s' % (ref_filter_regex, sys.exc_info()[1]))
2935 if ref_filter_do_send_regex and ref_filter_dont_send_regex:
2936 raise ConfigurationException(
2937 "Cannot specify both a ref doSend and dontSend regex.")
2938 if ref_filter_do_send_regex or ref_filter_dont_send_regex:
2939 self.__is_do_send_filter = bool(ref_filter_do_send_regex)
2940 if ref_filter_incl_regex:
2941 ref_filter_send_regex = ref_filter_incl_regex
2942 elif ref_filter_excl_regex:
2943 ref_filter_send_regex = ref_filter_excl_regex
2945 ref_filter_send_regex = '.*'
2946 self.__is_do_send_filter = True
2948 self.__send_compiled_regex = re.compile(ref_filter_send_regex)
2950 raise ConfigurationException(
2951 'Invalid Ref Filter Regex "%s": %s' %
2952 (ref_filter_send_regex, sys.exc_info()[1]))
2954 self.__send_compiled_regex = self.__compiled_regex
2955 self.__is_do_send_filter = self.__is_inclusion_filter
2957 def get_ref_filter_regex(self, send_filter=False):
2959 return self.__send_compiled_regex, self.__is_do_send_filter
2961 return self.__compiled_regex, self.__is_inclusion_filter
2964 class ConfigRefFilterEnvironmentMixin(
2965 ConfigEnvironmentMixin,
2966 StaticRefFilterEnvironmentMixin
2968 """Determine branch filtering statically based on config."""
2970 def _get_regex(self, config, key):
2971 """Get a list of whitespace-separated regex. The refFilter* config
2972 variables are multivalued (hence the use of get_all), and we
2973 allow each entry to be a whitespace-separated list (hence the
2974 split on each line). The whole thing is glued into a single regex."""
2975 values = config.get_all(key)
2980 for i in line.split():
2984 return '|'.join(items)
2986 def __init__(self, config, **kw):
2987 super(ConfigRefFilterEnvironmentMixin, self).__init__(
2989 ref_filter_incl_regex=self._get_regex(config, 'refFilterInclusionRegex'),
2990 ref_filter_excl_regex=self._get_regex(config, 'refFilterExclusionRegex'),
2991 ref_filter_do_send_regex=self._get_regex(config, 'refFilterDoSendRegex'),
2992 ref_filter_dont_send_regex=self._get_regex(config, 'refFilterDontSendRegex'),
2997 class ProjectdescEnvironmentMixin(Environment):
2998 """Make a "projectdesc" value available for templates.
3000 By default, it is set to the first line of $GIT_DIR/description
3001 (if that file is present and appears to be set meaningfully)."""
3003 def __init__(self, **kw):
3004 super(ProjectdescEnvironmentMixin, self).__init__(**kw)
3005 self.COMPUTED_KEYS += ['projectdesc']
3007 def get_projectdesc(self):
3008 """Return a one-line descripition of the project."""
3010 git_dir = get_git_dir()
3012 projectdesc = open(os.path.join(git_dir, 'description')).readline().strip()
3013 if projectdesc and not projectdesc.startswith('Unnamed repository'):
3018 return 'UNNAMED PROJECT'
3021 class GenericEnvironmentMixin(Environment):
3022 def get_pusher(self):
3023 return self.osenv.get('USER', self.osenv.get('USERNAME', 'unknown user'))
3026 class GenericEnvironment(
3027 ProjectdescEnvironmentMixin,
3028 ConfigMaxlinesEnvironmentMixin,
3029 ComputeFQDNEnvironmentMixin,
3030 ConfigFilterLinesEnvironmentMixin,
3031 ConfigRecipientsEnvironmentMixin,
3032 ConfigRefFilterEnvironmentMixin,
3033 PusherDomainEnvironmentMixin,
3034 ConfigOptionsEnvironmentMixin,
3035 GenericEnvironmentMixin,
3041 class GitoliteEnvironmentMixin(Environment):
3042 def get_repo_shortname(self):
3043 # The gitolite environment variable $GL_REPO is a pretty good
3044 # repo_shortname (though it's probably not as good as a value
3045 # the user might have explicitly put in his config).
3047 self.osenv.get('GL_REPO', None) or
3048 super(GitoliteEnvironmentMixin, self).get_repo_shortname()
3051 def get_pusher(self):
3052 return self.osenv.get('GL_USER', 'unknown user')
3054 def get_fromaddr(self, change=None):
3055 GL_USER = self.osenv.get('GL_USER')
3056 if GL_USER is not None:
3057 # Find the path to gitolite.conf. Note that gitolite v3
3058 # did away with the GL_ADMINDIR and GL_CONF environment
3059 # variables (they are now hard-coded).
3060 GL_ADMINDIR = self.osenv.get(
3062 os.path.expanduser(os.path.join('~', '.gitolite')))
3063 GL_CONF = self.osenv.get(
3065 os.path.join(GL_ADMINDIR, 'conf', 'gitolite.conf'))
3066 if os.path.isfile(GL_CONF):
3067 f = open(GL_CONF, 'rU')
3069 in_user_emails_section = False
3070 re_template = r'^\s*#\s*%s\s*$'
3071 re_begin, re_user, re_end = (
3072 re.compile(re_template % x)
3074 r'BEGIN\s+USER\s+EMAILS',
3075 re.escape(GL_USER) + r'\s+(.*)',
3076 r'END\s+USER\s+EMAILS',
3080 if not in_user_emails_section:
3081 if re_begin.match(l):
3082 in_user_emails_section = True
3086 m = re_user.match(l)
3091 return super(GitoliteEnvironmentMixin, self).get_fromaddr(change)
3094 class IncrementalDateTime(object):
3095 """Simple wrapper to give incremental date/times.
3097 Each call will result in a date/time a second later than the
3098 previous call. This can be used to falsify email headers, to
3099 increase the likelihood that email clients sort the emails
3103 self.time = time.time()
3104 self.next = self.__next__ # Python 2 backward compatibility
3107 formatted = formatdate(self.time, True)
3112 class GitoliteEnvironment(
3113 ProjectdescEnvironmentMixin,
3114 ConfigMaxlinesEnvironmentMixin,
3115 ComputeFQDNEnvironmentMixin,
3116 ConfigFilterLinesEnvironmentMixin,
3117 ConfigRecipientsEnvironmentMixin,
3118 ConfigRefFilterEnvironmentMixin,
3119 PusherDomainEnvironmentMixin,
3120 ConfigOptionsEnvironmentMixin,
3121 GitoliteEnvironmentMixin,
3127 class StashEnvironmentMixin(Environment):
3128 def __init__(self, user=None, repo=None, **kw):
3129 super(StashEnvironmentMixin, self).__init__(**kw)
3133 def get_repo_shortname(self):
3136 def get_pusher(self):
3137 return re.match('(.*?)\s*<', self.__user).group(1)
3139 def get_pusher_email(self):
3142 def get_fromaddr(self, change=None):
3146 class StashEnvironment(
3147 StashEnvironmentMixin,
3148 ProjectdescEnvironmentMixin,
3149 ConfigMaxlinesEnvironmentMixin,
3150 ComputeFQDNEnvironmentMixin,
3151 ConfigFilterLinesEnvironmentMixin,
3152 ConfigRecipientsEnvironmentMixin,
3153 ConfigRefFilterEnvironmentMixin,
3154 PusherDomainEnvironmentMixin,
3155 ConfigOptionsEnvironmentMixin,
3161 class GerritEnvironmentMixin(Environment):
3162 def __init__(self, project=None, submitter=None, update_method=None, **kw):
3163 super(GerritEnvironmentMixin, self).__init__(**kw)
3164 self.__project = project
3165 self.__submitter = submitter
3166 self.__update_method = update_method
3167 "Make an 'update_method' value available for templates."
3168 self.COMPUTED_KEYS += ['update_method']
3170 def get_repo_shortname(self):
3171 return self.__project
3173 def get_pusher(self):
3174 if self.__submitter:
3175 if self.__submitter.find('<') != -1:
3176 # Submitter has a configured email, we transformed
3177 # __submitter into an RFC 2822 string already.
3178 return re.match('(.*?)\s*<', self.__submitter).group(1)
3180 # Submitter has no configured email, it's just his name.
3181 return self.__submitter
3183 # If we arrive here, this means someone pushed "Submit" from
3184 # the gerrit web UI for the CR (or used one of the programmatic
3185 # APIs to do the same, such as gerrit review) and the
3186 # merge/push was done by the Gerrit user. It was technically
3187 # triggered by someone else, but sadly we have no way of
3188 # determining who that someone else is at this point.
3189 return 'Gerrit' # 'unknown user'?
3191 def get_pusher_email(self):
3192 if self.__submitter:
3193 return self.__submitter
3195 return super(GerritEnvironmentMixin, self).get_pusher_email()
3197 def get_fromaddr(self, change=None):
3198 if self.__submitter and self.__submitter.find('<') != -1:
3199 return self.__submitter
3201 return super(GerritEnvironmentMixin, self).get_fromaddr(change)
3203 def get_default_ref_ignore_regex(self):
3204 default = super(GerritEnvironmentMixin, self).get_default_ref_ignore_regex()
3205 return default + '|^refs/changes/|^refs/cache-automerge/|^refs/meta/'
3207 def get_revision_recipients(self, revision):
3208 # Merge commits created by Gerrit when users hit "Submit this patchset"
3209 # in the Web UI (or do equivalently with REST APIs or the gerrit review
3210 # command) are not something users want to see an individual email for.
3212 committer = read_git_output(['log', '--no-walk', '--format=%cN',
3214 if committer == 'Gerrit Code Review':
3217 return super(GerritEnvironmentMixin, self).get_revision_recipients(revision)
3219 def get_update_method(self):
3220 return self.__update_method
3223 class GerritEnvironment(
3224 GerritEnvironmentMixin,
3225 ProjectdescEnvironmentMixin,
3226 ConfigMaxlinesEnvironmentMixin,
3227 ComputeFQDNEnvironmentMixin,
3228 ConfigFilterLinesEnvironmentMixin,
3229 ConfigRecipientsEnvironmentMixin,
3230 ConfigRefFilterEnvironmentMixin,
3231 PusherDomainEnvironmentMixin,
3232 ConfigOptionsEnvironmentMixin,
3239 """Represent an entire push (i.e., a group of ReferenceChanges).
3241 It is easy to figure out what commits were added to a *branch* by
3244 git rev-list change.old..change.new
3246 or removed from a *branch*:
3248 git rev-list change.new..change.old
3250 But it is not quite so trivial to determine which entirely new
3251 commits were added to the *repository* by a push and which old
3252 commits were discarded by a push. A big part of the job of this
3253 class is to figure out these things, and to make sure that new
3254 commits are only detailed once even if they were added to multiple
3257 The first step is to determine the "other" references--those
3258 unaffected by the current push. They are computed by listing all
3259 references then removing any affected by this push. The results
3260 are stored in Push._other_ref_sha1s.
3262 The commits contained in the repository before this push were
3264 git rev-list other1 other2 other3 ... change1.old change2.old ...
3266 Where "changeN.old" is the old value of one of the references
3267 affected by this push.
3269 The commits contained in the repository after this push are
3271 git rev-list other1 other2 other3 ... change1.new change2.new ...
3273 The commits added by this push are the difference between these
3274 two sets, which can be written
3277 ^other1 ^other2 ... \
3278 ^change1.old ^change2.old ... \
3279 change1.new change2.new ...
3281 The commits removed by this push can be computed by
3284 ^other1 ^other2 ... \
3285 ^change1.new ^change2.new ... \
3286 change1.old change2.old ...
3288 The last point is that it is possible that other pushes are
3289 occurring simultaneously to this one, so reference values can
3290 change at any time. It is impossible to eliminate all race
3291 conditions, but we reduce the window of time during which problems
3292 can occur by translating reference names to SHA1s as soon as
3293 possible and working with SHA1s thereafter (because SHA1s are
3296 # A map {(changeclass, changetype): integer} specifying the order
3297 # that reference changes will be processed if multiple reference
3298 # changes are included in a single push. The order is significant
3299 # mostly because new commit notifications are threaded together
3300 # with the first reference change that includes the commit. The
3301 # following order thus causes commits to be grouped with branch
3302 # changes (as opposed to tag changes) if possible.
3304 (value, i) for (i, value) in enumerate([
3305 (BranchChange, 'update'),
3306 (BranchChange, 'create'),
3307 (AnnotatedTagChange, 'update'),
3308 (AnnotatedTagChange, 'create'),
3309 (NonAnnotatedTagChange, 'update'),
3310 (NonAnnotatedTagChange, 'create'),
3311 (BranchChange, 'delete'),
3312 (AnnotatedTagChange, 'delete'),
3313 (NonAnnotatedTagChange, 'delete'),
3314 (OtherReferenceChange, 'update'),
3315 (OtherReferenceChange, 'create'),
3316 (OtherReferenceChange, 'delete'),
3320 def __init__(self, environment, changes, ignore_other_refs=False):
3321 self.changes = sorted(changes, key=self._sort_key)
3322 self.__other_ref_sha1s = None
3323 self.__cached_commits_spec = {}
3324 self.environment = environment
3326 if ignore_other_refs:
3327 self.__other_ref_sha1s = set()
3330 def _sort_key(klass, change):
3331 return (klass.SORT_ORDER[change.__class__, change.change_type], change.refname,)
3334 def _other_ref_sha1s(self):
3335 """The GitObjects referred to by references unaffected by this push.
3337 if self.__other_ref_sha1s is None:
3338 # The refnames being changed by this push:
3341 for change in self.changes
3344 # The SHA-1s of commits referred to by all references in this
3345 # repository *except* updated_refs:
3348 '%(objectname) %(objecttype) %(refname)\n'
3349 '%(*objectname) %(*objecttype) %(refname)'
3351 ref_filter_regex, is_inclusion_filter = \
3352 self.environment.get_ref_filter_regex()
3353 for line in read_git_lines(
3354 ['for-each-ref', '--format=%s' % (fmt,)]):
3355 (sha1, type, name) = line.split(' ', 2)
3356 if (sha1 and type == 'commit' and
3357 name not in updated_refs and
3358 include_ref(name, ref_filter_regex, is_inclusion_filter)):
3361 self.__other_ref_sha1s = sha1s
3363 return self.__other_ref_sha1s
3365 def _get_commits_spec_incl(self, new_or_old, reference_change=None):
3366 """Get new or old SHA-1 from one or each of the changed refs.
3368 Return a list of SHA-1 commit identifier strings suitable as
3369 arguments to 'git rev-list' (or 'git log' or ...). The
3370 returned identifiers are either the old or new values from one
3371 or all of the changed references, depending on the values of
3372 new_or_old and reference_change.
3374 new_or_old is either the string 'new' or the string 'old'. If
3375 'new', the returned SHA-1 identifiers are the new values from
3376 each changed reference. If 'old', the SHA-1 identifiers are
3377 the old values from each changed reference.
3379 If reference_change is specified and not None, only the new or
3380 old reference from the specified reference is included in the
3383 This function returns None if there are no matching revisions
3384 (e.g., because a branch was deleted and new_or_old is 'new').
3387 if not reference_change:
3389 getattr(change, new_or_old).sha1
3390 for change in self.changes
3391 if getattr(change, new_or_old)
3395 elif not getattr(reference_change, new_or_old).commit_sha1:
3398 incl_spec = [getattr(reference_change, new_or_old).commit_sha1]
3401 def _get_commits_spec_excl(self, new_or_old):
3402 """Get exclusion revisions for determining new or discarded commits.
3404 Return a list of strings suitable as arguments to 'git
3405 rev-list' (or 'git log' or ...) that will exclude all
3406 commits that, depending on the value of new_or_old, were
3407 either previously in the repository (useful for determining
3408 which commits are new to the repository) or currently in the
3409 repository (useful for determining which commits were
3410 discarded from the repository).
3412 new_or_old is either the string 'new' or the string 'old'. If
3413 'new', the commits to be excluded are those that were in the
3414 repository before the push. If 'old', the commits to be
3415 excluded are those that are currently in the repository. """
3417 old_or_new = {'old': 'new', 'new': 'old'}[new_or_old]
3418 excl_revs = self._other_ref_sha1s.union(
3419 getattr(change, old_or_new).sha1
3420 for change in self.changes
3421 if getattr(change, old_or_new).type in ['commit', 'tag']
3423 return ['^' + sha1 for sha1 in sorted(excl_revs)]
3425 def get_commits_spec(self, new_or_old, reference_change=None):
3426 """Get rev-list arguments for added or discarded commits.
3428 Return a list of strings suitable as arguments to 'git
3429 rev-list' (or 'git log' or ...) that select those commits
3430 that, depending on the value of new_or_old, are either new to
3431 the repository or were discarded from the repository.
3433 new_or_old is either the string 'new' or the string 'old'. If
3434 'new', the returned list is used to select commits that are
3435 new to the repository. If 'old', the returned value is used
3436 to select the commits that have been discarded from the
3439 If reference_change is specified and not None, the new or
3440 discarded commits are limited to those that are reachable from
3441 the new or old value of the specified reference.
3443 This function returns None if there are no added (or discarded)
3446 key = (new_or_old, reference_change)
3447 if key not in self.__cached_commits_spec:
3448 ret = self._get_commits_spec_incl(new_or_old, reference_change)
3450 ret.extend(self._get_commits_spec_excl(new_or_old))
3451 self.__cached_commits_spec[key] = ret
3452 return self.__cached_commits_spec[key]
3454 def get_new_commits(self, reference_change=None):
3455 """Return a list of commits added by this push.
3457 Return a list of the object names of commits that were added
3458 by the part of this push represented by reference_change. If
3459 reference_change is None, then return a list of *all* commits
3460 added by this push."""
3462 spec = self.get_commits_spec('new', reference_change)
3463 return git_rev_list(spec)
3465 def get_discarded_commits(self, reference_change):
3466 """Return a list of commits discarded by this push.
3468 Return a list of the object names of commits that were
3469 entirely discarded from the repository by the part of this
3470 push represented by reference_change."""
3472 spec = self.get_commits_spec('old', reference_change)
3473 return git_rev_list(spec)
3475 def send_emails(self, mailer, body_filter=None):
3476 """Use send all of the notification emails needed for this push.
3478 Use send all of the notification emails (including reference
3479 change emails and commit emails) needed for this push. Send
3480 the emails using mailer. If body_filter is not None, then use
3481 it to filter the lines that are intended for the email
3484 # The sha1s of commits that were introduced by this push.
3485 # They will be removed from this set as they are processed, to
3486 # guarantee that one (and only one) email is generated for
3488 unhandled_sha1s = set(self.get_new_commits())
3489 send_date = IncrementalDateTime()
3490 for change in self.changes:
3492 for sha1 in reversed(list(self.get_new_commits(change))):
3493 if sha1 in unhandled_sha1s:
3495 unhandled_sha1s.remove(sha1)
3497 # Check if we've got anyone to send to
3498 if not change.recipients:
3499 change.environment.log_warning(
3500 '*** no recipients configured so no email will be sent\n'
3501 '*** for %r update %s->%s\n'
3502 % (change.refname, change.old.sha1, change.new.sha1,)
3505 if not change.environment.quiet:
3506 change.environment.log_msg(
3507 'Sending notification emails to: %s\n' % (change.recipients,))
3508 extra_values = {'send_date': next(send_date)}
3510 rev = change.send_single_combined_email(sha1s)
3513 change.generate_combined_email(self, rev, body_filter, extra_values),
3516 # This change is now fully handled; no need to handle
3517 # individual revisions any further.
3521 change.generate_email(self, body_filter, extra_values),
3525 max_emails = change.environment.maxcommitemails
3526 if max_emails and len(sha1s) > max_emails:
3527 change.environment.log_warning(
3528 '*** Too many new commits (%d), not sending commit emails.\n' % len(sha1s) +
3529 '*** Try setting multimailhook.maxCommitEmails to a greater value\n' +
3530 '*** Currently, multimailhook.maxCommitEmails=%d\n' % max_emails
3534 for (num, sha1) in enumerate(sha1s):
3535 rev = Revision(change, GitObject(sha1), num=num + 1, tot=len(sha1s))
3536 if not rev.recipients and rev.cc_recipients:
3537 change.environment.log_msg('*** Replacing Cc: with To:\n')
3538 rev.recipients = rev.cc_recipients
3539 rev.cc_recipients = None
3541 extra_values = {'send_date': next(send_date)}
3543 rev.generate_email(self, body_filter, extra_values),
3547 # Consistency check:
3549 change.environment.log_error(
3550 'ERROR: No emails were sent for the following new commits:\n'
3552 % ('\n '.join(sorted(unhandled_sha1s)),)
3556 def include_ref(refname, ref_filter_regex, is_inclusion_filter):
3557 does_match = bool(ref_filter_regex.search(refname))
3558 if is_inclusion_filter:
3560 else: # exclusion filter -- we include the ref if the regex doesn't match
3561 return not does_match
3564 def run_as_post_receive_hook(environment, mailer):
3565 ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(True)
3567 for line in sys.stdin:
3568 (oldrev, newrev, refname) = line.strip().split(' ', 2)
3569 if not include_ref(refname, ref_filter_regex, is_inclusion_filter):
3572 ReferenceChange.create(environment, oldrev, newrev, refname)
3575 push = Push(environment, changes)
3576 push.send_emails(mailer, body_filter=environment.filter_body)
3577 if hasattr(mailer, '__del__'):
3581 def run_as_update_hook(environment, mailer, refname, oldrev, newrev, force_send=False):
3582 ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(True)
3583 if not include_ref(refname, ref_filter_regex, is_inclusion_filter):
3586 ReferenceChange.create(
3588 read_git_output(['rev-parse', '--verify', oldrev]),
3589 read_git_output(['rev-parse', '--verify', newrev]),
3593 push = Push(environment, changes, force_send)
3594 push.send_emails(mailer, body_filter=environment.filter_body)
3595 if hasattr(mailer, '__del__'):
3599 def choose_mailer(config, environment):
3600 mailer = config.get('mailer', default='sendmail')
3602 if mailer == 'smtp':
3603 smtpserver = config.get('smtpserver', default='localhost')
3604 smtpservertimeout = float(config.get('smtpservertimeout', default=10.0))
3605 smtpserverdebuglevel = int(config.get('smtpserverdebuglevel', default=0))
3606 smtpencryption = config.get('smtpencryption', default='none')
3607 smtpuser = config.get('smtpuser', default='')
3608 smtppass = config.get('smtppass', default='')
3609 smtpcacerts = config.get('smtpcacerts', default='')
3610 mailer = SMTPMailer(
3611 envelopesender=(environment.get_sender() or environment.get_fromaddr()),
3612 smtpserver=smtpserver, smtpservertimeout=smtpservertimeout,
3613 smtpserverdebuglevel=smtpserverdebuglevel,
3614 smtpencryption=smtpencryption,
3617 smtpcacerts=smtpcacerts
3619 elif mailer == 'sendmail':
3620 command = config.get('sendmailcommand')
3622 command = shlex.split(command)
3623 mailer = SendMailer(command=command, envelopesender=environment.get_sender())
3625 environment.log_error(
3626 'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n' % mailer +
3627 'please use one of "smtp" or "sendmail".\n'
3633 KNOWN_ENVIRONMENTS = {
3634 'generic': GenericEnvironmentMixin,
3635 'gitolite': GitoliteEnvironmentMixin,
3636 'stash': StashEnvironmentMixin,
3637 'gerrit': GerritEnvironmentMixin,
3641 def choose_environment(config, osenv=None, env=None, recipients=None,
3646 environment_mixins = [
3647 ConfigRefFilterEnvironmentMixin,
3648 ProjectdescEnvironmentMixin,
3649 ConfigMaxlinesEnvironmentMixin,
3650 ComputeFQDNEnvironmentMixin,
3651 ConfigFilterLinesEnvironmentMixin,
3652 PusherDomainEnvironmentMixin,
3653 ConfigOptionsEnvironmentMixin,
3661 env = config.get('environment')
3664 if 'GL_USER' in osenv and 'GL_REPO' in osenv:
3669 environment_mixins.insert(0, KNOWN_ENVIRONMENTS[env])
3672 environment_kw['user'] = hook_info['stash_user']
3673 environment_kw['repo'] = hook_info['stash_repo']
3674 elif env == 'gerrit':
3675 environment_kw['project'] = hook_info['project']
3676 environment_kw['submitter'] = hook_info['submitter']
3677 environment_kw['update_method'] = hook_info['update_method']
3680 environment_mixins.insert(0, StaticRecipientsEnvironmentMixin)
3681 environment_kw['refchange_recipients'] = recipients
3682 environment_kw['announce_recipients'] = recipients
3683 environment_kw['revision_recipients'] = recipients
3684 environment_kw['scancommitforcc'] = config.get('scancommitforcc')
3686 environment_mixins.insert(0, ConfigRecipientsEnvironmentMixin)
3688 environment_klass = type(
3689 'EffectiveEnvironment',
3690 tuple(environment_mixins) + (Environment,),
3693 return environment_klass(**environment_kw)
3697 oldcwd = os.getcwd()
3700 os.chdir(os.path.dirname(os.path.realpath(__file__)))
3701 git_version = read_git_output(['describe', '--tags', 'HEAD'])
3702 if git_version == __version__:
3705 return '%s (%s)' % (__version__, git_version)
3713 def compute_gerrit_options(options, args, required_gerrit_options):
3714 if None in required_gerrit_options:
3715 raise SystemExit("Error: Specify all of --oldrev, --newrev, --refname, "
3716 "and --project; or none of them.")
3718 if options.environment not in (None, 'gerrit'):
3719 raise SystemExit("Non-gerrit environments incompatible with --oldrev, "
3720 "--newrev, --refname, and --project")
3721 options.environment = 'gerrit'
3724 raise SystemExit("Error: Positional parameters not allowed with "
3725 "--oldrev, --newrev, and --refname.")
3727 # Gerrit oddly omits 'refs/heads/' in the refname when calling
3728 # ref-updated hook; put it back.
3729 git_dir = get_git_dir()
3730 if (not os.path.exists(os.path.join(git_dir, options.refname)) and
3731 os.path.exists(os.path.join(git_dir, 'refs', 'heads',
3733 options.refname = 'refs/heads/' + options.refname
3735 # Convert each string option unicode for Python3.
3737 opts = ['environment', 'recipients', 'oldrev', 'newrev', 'refname',
3738 'project', 'submitter', 'stash-user', 'stash-repo']
3740 if not hasattr(options, opt):
3742 obj = getattr(options, opt)
3744 enc = obj.encode('utf-8', 'surrogateescape')
3745 dec = enc.decode('utf-8', 'replace')
3746 setattr(options, opt, dec)
3748 # New revisions can appear in a gerrit repository either due to someone
3749 # pushing directly (in which case options.submitter will be set), or they
3750 # can press "Submit this patchset" in the web UI for some CR (in which
3751 # case options.submitter will not be set and gerrit will not have provided
3752 # us the information about who pressed the button).
3754 # Note for the nit-picky: I'm lumping in REST API calls and the ssh
3755 # gerrit review command in with "Submit this patchset" button, since they
3756 # have the same effect.
3757 if options.submitter:
3758 update_method = 'pushed'
3759 # The submitter argument is almost an RFC 2822 email address; change it
3760 # from 'User Name (email@domain)' to 'User Name <email@domain>' so it is
3761 options.submitter = options.submitter.replace('(', '<').replace(')', '>')
3763 update_method = 'submitted'
3764 # Gerrit knew who submitted this patchset, but threw that information
3765 # away when it invoked this hook. However, *IF* Gerrit created a
3766 # merge to bring the patchset in (project 'Submit Type' is either
3767 # "Always Merge", or is "Merge if Necessary" and happens to be
3768 # necessary for this particular CR), then it will have the committer
3769 # of that merge be 'Gerrit Code Review' and the author will be the
3770 # person who requested the submission of the CR. Since this is fairly
3771 # likely for most gerrit installations (of a reasonable size), it's
3772 # worth the extra effort to try to determine the actual submitter.
3773 rev_info = read_git_lines(['log', '--no-walk', '--merges',
3774 '--format=%cN%n%aN <%aE>', options.newrev])
3775 if rev_info and rev_info[0] == 'Gerrit Code Review':
3776 options.submitter = rev_info[1]
3778 # We pass back refname, oldrev, newrev as args because then the
3779 # gerrit ref-updated hook is much like the git update hook
3781 [options.refname, options.oldrev, options.newrev],
3782 {'project': options.project, 'submitter': options.submitter,
3783 'update_method': update_method})
3786 def check_hook_specific_args(options, args):
3787 # First check for stash arguments
3788 if (options.stash_user is None) != (options.stash_repo is None):
3789 raise SystemExit("Error: Specify both of --stash-user and "
3790 "--stash-repo or neither.")
3791 if options.stash_user:
3792 options.environment = 'stash'
3793 return options, args, {'stash_user': options.stash_user,
3794 'stash_repo': options.stash_repo}
3796 # Finally, check for gerrit specific arguments
3797 required_gerrit_options = (options.oldrev, options.newrev, options.refname,
3799 if required_gerrit_options != (None,) * 4:
3800 return compute_gerrit_options(options, args, required_gerrit_options)
3802 # No special options in use, just return what we started with
3803 return options, args, {}
3807 parser = optparse.OptionParser(
3808 description=__doc__,
3809 usage='%prog [OPTIONS]\n or: %prog [OPTIONS] REFNAME OLDREV NEWREV',
3813 '--environment', '--env', action='store', type='choice',
3814 choices=list(KNOWN_ENVIRONMENTS.keys()), default=None,
3816 'Choose type of environment is in use. Default is taken from '
3817 'multimailhook.environment if set; otherwise "generic".'
3821 '--stdout', action='store_true', default=False,
3822 help='Output emails to stdout rather than sending them.',
3825 '--recipients', action='store', default=None,
3826 help='Set list of email recipients for all types of emails.',
3829 '--show-env', action='store_true', default=False,
3831 'Write to stderr the values determined for the environment '
3832 '(intended for debugging purposes).'
3836 '--force-send', action='store_true', default=False,
3838 'Force sending refchange email when using as an update hook. '
3839 'This is useful to work around the unreliable new commits '
3840 'detection in this mode.'
3844 '-c', metavar="<name>=<value>", action='append',
3846 'Pass a configuration parameter through to git. The value given '
3847 'will override values from configuration files. See the -c option '
3848 'of git(1) for more details. (Only works with git >= 1.7.3)'
3852 '--version', '-v', action='store_true', default=False,
3854 "Display git-multimail's version"
3857 # The following options permit this script to be run as a gerrit
3858 # ref-updated hook. See e.g.
3859 # code.google.com/p/gerrit/source/browse/Documentation/config-hooks.txt
3860 # We suppress help for these items, since these are specific to gerrit,
3861 # and we don't want users directly using them any way other than how the
3862 # gerrit ref-updated hook is called.
3863 parser.add_option('--oldrev', action='store', help=optparse.SUPPRESS_HELP)
3864 parser.add_option('--newrev', action='store', help=optparse.SUPPRESS_HELP)
3865 parser.add_option('--refname', action='store', help=optparse.SUPPRESS_HELP)
3866 parser.add_option('--project', action='store', help=optparse.SUPPRESS_HELP)
3867 parser.add_option('--submitter', action='store', help=optparse.SUPPRESS_HELP)
3869 # The following allow this to be run as a stash asynchronous post-receive
3870 # hook (almost identical to a git post-receive hook but triggered also for
3871 # merges of pull requests from the UI). We suppress help for these items,
3872 # since these are specific to stash.
3873 parser.add_option('--stash-user', action='store', help=optparse.SUPPRESS_HELP)
3874 parser.add_option('--stash-repo', action='store', help=optparse.SUPPRESS_HELP)
3876 (options, args) = parser.parse_args(args)
3877 (options, args, hook_info) = check_hook_specific_args(options, args)
3880 sys.stdout.write('git-multimail version ' + get_version() + '\n')
3884 Config.add_config_parameters(options.c)
3886 config = Config('multimailhook')
3889 environment = choose_environment(
3890 config, osenv=os.environ,
3891 env=options.environment,
3892 recipients=options.recipients,
3893 hook_info=hook_info,
3896 if options.show_env:
3897 sys.stderr.write('Environment values:\n')
3898 for (k, v) in sorted(environment.get_values().items()):
3899 sys.stderr.write(' %s : %r\n' % (k, v))
3900 sys.stderr.write('\n')
3902 if options.stdout or environment.stdout:
3903 mailer = OutputMailer(sys.stdout)
3905 mailer = choose_mailer(config, environment)
3907 # Dual mode: if arguments were specified on the command line, run
3908 # like an update hook; otherwise, run as a post-receive hook.
3911 parser.error('Need zero or three non-option arguments')
3912 (refname, oldrev, newrev) = args
3913 run_as_update_hook(environment, mailer, refname, oldrev, newrev, options.force_send)
3915 run_as_post_receive_hook(environment, mailer)
3916 except ConfigurationException:
3917 sys.exit(sys.exc_info()[1])
3919 t, e, tb = sys.exc_info()
3921 sys.stdout.write('\n')
3922 sys.stdout.write('Exception \'' + t.__name__ +
3923 '\' raised. Please report this as a bug to\n')
3924 sys.stdout.write('https://github.com/git-multimail/git-multimail/issues\n')
3925 sys.stdout.write('with the information below:\n\n')
3926 sys.stdout.write('git-multimail version ' + get_version() + '\n')
3927 sys.stdout.write('Python version ' + sys.version + '\n')
3928 traceback.print_exc(file=sys.stdout)
3931 if __name__ == '__main__':