5 # Copyright (c) 2015-2016 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.
64 # Python < 2.6 do not have ssl, but that's OK if we don't use it.
71 PYTHON3 = sys.version_info >= (3, 0)
73 if sys.version_info <= (2, 5):
75 for element in iterable:
82 return all(ord(c) < 128 and ord(c) > 0 for c in s)
87 return isinstance(s, str)
90 return s.encode(ENCODING)
92 def bytes_to_str(s, errors='strict'):
93 return s.decode(ENCODING, errors)
97 def write_str(f, msg):
98 # Try outputting with the default encoding. If it fails,
101 f.buffer.write(msg.encode(sys.getdefaultencoding()))
102 except UnicodeEncodeError:
103 f.buffer.write(msg.encode(ENCODING))
106 # Try reading with the default encoding. If it fails,
108 out = f.buffer.readline()
110 return out.decode(sys.getdefaultencoding())
111 except UnicodeEncodeError:
112 return out.decode(ENCODING)
117 return html.escape(s)
122 return isinstance(s, basestring)
123 except NameError: # Silence Pyflakes warning
129 def bytes_to_str(s, errors='strict'):
132 def write_str(f, msg):
144 return cgi.escape(s, True)
147 from email.charset import Charset
148 from email.utils import make_msgid
149 from email.utils import getaddresses
150 from email.utils import formataddr
151 from email.utils import formatdate
152 from email.header import Header
154 # Prior to Python 2.5, the email module used different names:
155 from email.Charset import Charset
156 from email.Utils import make_msgid
157 from email.Utils import getaddresses
158 from email.Utils import formataddr
159 from email.Utils import formatdate
160 from email.Header import Header
166 LOGBEGIN = '- Log -----------------------------------------------------------------\n'
167 LOGEND = '-----------------------------------------------------------------------\n'
169 ADDR_HEADERS = set(['from', 'to', 'cc', 'bcc', 'reply-to', 'sender'])
171 # It is assumed in many places that the encoding is uniformly UTF-8,
172 # so changing these constants is unsupported. But define them here
173 # anyway, to make it easier to find (at least most of) the places
174 # where the encoding is important.
175 (ENCODING, CHARSET) = ('UTF-8', 'utf-8')
178 REF_CREATED_SUBJECT_TEMPLATE = (
179 '%(emailprefix)s%(refname_type)s %(short_refname)s created'
180 ' (now %(newrev_short)s)'
182 REF_UPDATED_SUBJECT_TEMPLATE = (
183 '%(emailprefix)s%(refname_type)s %(short_refname)s updated'
184 ' (%(oldrev_short)s -> %(newrev_short)s)'
186 REF_DELETED_SUBJECT_TEMPLATE = (
187 '%(emailprefix)s%(refname_type)s %(short_refname)s deleted'
188 ' (was %(oldrev_short)s)'
191 COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE = (
192 '%(emailprefix)s%(refname_type)s %(short_refname)s updated: %(oneline)s'
195 REFCHANGE_HEADER_TEMPLATE = """\
200 Content-Type: text/%(contenttype)s; charset=%(charset)s
201 Content-Transfer-Encoding: 8bit
202 Message-ID: %(msgid)s
204 Reply-To: %(reply_to)s
205 Thread-Index: %(thread_index)s
207 X-Git-Repo: %(repo_shortname)s
208 X-Git-Refname: %(refname)s
209 X-Git-Reftype: %(refname_type)s
210 X-Git-Oldrev: %(oldrev)s
211 X-Git-Newrev: %(newrev)s
212 X-Git-NotificationType: ref_changed
213 X-Git-Multimail-Version: %(multimail_version)s
214 Auto-Submitted: auto-generated
217 REFCHANGE_INTRO_TEMPLATE = """\
218 This is an automated email from the git hooks/post-receive script.
220 %(pusher)s pushed a change to %(refname_type)s %(short_refname)s
221 in repository %(repo_shortname)s.
226 FOOTER_TEMPLATE = """\
229 To stop receiving notification emails like this one, please contact
234 REWIND_ONLY_TEMPLATE = """\
235 This update removed existing revisions from the reference, leaving the
236 reference pointing at a previous point in the repository history.
238 * -- * -- N %(refname)s (%(newrev_short)s)
240 O -- O -- O (%(oldrev_short)s)
242 Any revisions marked "omit" are not gone; other references still
243 refer to them. Any revisions marked "discard" are gone forever.
247 NON_FF_TEMPLATE = """\
248 This update added new revisions after undoing existing revisions.
249 That is to say, some revisions that were in the old version of the
250 %(refname_type)s are not in the new version. This situation occurs
251 when a user --force pushes a change and generates a repository
252 containing something like this:
254 * -- * -- B -- O -- O -- O (%(oldrev_short)s)
256 N -- N -- N %(refname)s (%(newrev_short)s)
258 You should already have received notification emails for all of the O
259 revisions, and so the following emails describe only the N revisions
260 from the common base, B.
262 Any revisions marked "omit" are not gone; other references still
263 refer to them. Any revisions marked "discard" are gone forever.
267 NO_NEW_REVISIONS_TEMPLATE = """\
268 No new revisions were added by this update.
272 DISCARDED_REVISIONS_TEMPLATE = """\
273 This change permanently discards the following revisions:
277 NO_DISCARDED_REVISIONS_TEMPLATE = """\
278 The revisions that were on this %(refname_type)s are still contained in
279 other references; therefore, this change does not discard any commits
284 NEW_REVISIONS_TEMPLATE = """\
285 The %(tot)s revisions listed above as "new" are entirely new to this
286 repository and will be described in separate emails. The revisions
287 listed as "add" were already present in the repository and have only
288 been added to this reference.
293 TAG_CREATED_TEMPLATE = """\
294 at %(newrev_short)-8s (%(newrev_type)s)
298 TAG_UPDATED_TEMPLATE = """\
299 *** WARNING: tag %(short_refname)s was modified! ***
301 from %(oldrev_short)-8s (%(oldrev_type)s)
302 to %(newrev_short)-8s (%(newrev_type)s)
306 TAG_DELETED_TEMPLATE = """\
307 *** WARNING: tag %(short_refname)s was deleted! ***
312 # The template used in summary tables. It looks best if this uses the
313 # same alignment as TAG_CREATED_TEMPLATE and TAG_UPDATED_TEMPLATE.
314 BRIEF_SUMMARY_TEMPLATE = """\
315 %(action)8s %(rev_short)-8s %(text)s
319 NON_COMMIT_UPDATE_TEMPLATE = """\
320 This is an unusual reference change because the reference did not
321 refer to a commit either before or after the change. We do not know
322 how to provide full information about this reference change.
326 REVISION_HEADER_TEMPLATE = """\
329 Cc: %(cc_recipients)s
330 Subject: %(emailprefix)s%(num)02d/%(tot)02d: %(oneline)s
332 Content-Type: text/%(contenttype)s; charset=%(charset)s
333 Content-Transfer-Encoding: 8bit
335 Reply-To: %(reply_to)s
336 In-Reply-To: %(reply_to_msgid)s
337 References: %(reply_to_msgid)s
338 Thread-Index: %(thread_index)s
340 X-Git-Repo: %(repo_shortname)s
341 X-Git-Refname: %(refname)s
342 X-Git-Reftype: %(refname_type)s
344 X-Git-NotificationType: diff
345 X-Git-Multimail-Version: %(multimail_version)s
346 Auto-Submitted: auto-generated
349 REVISION_INTRO_TEMPLATE = """\
350 This is an automated email from the git hooks/post-receive script.
352 %(pusher)s pushed a commit to %(refname_type)s %(short_refname)s
353 in repository %(repo_shortname)s.
357 LINK_TEXT_TEMPLATE = """\
358 View the commit online:
363 LINK_HTML_TEMPLATE = """\
364 <p><a href="%(browse_url)s">View the commit online</a>.</p>
368 REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE
371 # Combined, meaning refchange+revision email (for single-commit additions)
372 COMBINED_HEADER_TEMPLATE = """\
377 Content-Type: text/%(contenttype)s; charset=%(charset)s
378 Content-Transfer-Encoding: 8bit
379 Message-ID: %(msgid)s
381 Reply-To: %(reply_to)s
383 X-Git-Repo: %(repo_shortname)s
384 X-Git-Refname: %(refname)s
385 X-Git-Reftype: %(refname_type)s
386 X-Git-Oldrev: %(oldrev)s
387 X-Git-Newrev: %(newrev)s
389 X-Git-NotificationType: ref_changed_plus_diff
390 X-Git-Multimail-Version: %(multimail_version)s
391 Auto-Submitted: auto-generated
394 COMBINED_INTRO_TEMPLATE = """\
395 This is an automated email from the git hooks/post-receive script.
397 %(pusher)s pushed a commit to %(refname_type)s %(short_refname)s
398 in repository %(repo_shortname)s.
402 COMBINED_FOOTER_TEMPLATE = FOOTER_TEMPLATE
405 class CommandError(Exception):
406 def __init__(self, cmd, retcode):
408 self.retcode = retcode
411 'Command "%s" failed with retcode %s' % (' '.join(cmd), retcode,)
415 class ConfigurationException(Exception):
419 # The "git" program (this could be changed to include a full path):
420 GIT_EXECUTABLE = 'git'
423 # How "git" should be invoked (including global arguments), as a list
424 # of words. This variable is usually initialized automatically by
425 # read_git_output() via choose_git_command(), but if a value is set
426 # here then it will be used unconditionally.
430 def choose_git_command():
431 """Decide how to invoke git, and record the choice in GIT_CMD."""
437 # Check to see whether the "-c" option is accepted (it was
438 # only added in Git 1.7.2). We don't actually use the
439 # output of "git --version", though if we needed more
440 # specific version information this would be the place to
442 cmd = [GIT_EXECUTABLE, '-c', 'foo.bar=baz', '--version']
444 GIT_CMD = [GIT_EXECUTABLE, '-c', 'i18n.logoutputencoding=%s' % (ENCODING,)]
446 GIT_CMD = [GIT_EXECUTABLE]
449 def read_git_output(args, input=None, keepends=False, **kw):
450 """Read the output of a Git command."""
455 return read_output(GIT_CMD + args, input=input, keepends=keepends, **kw)
458 def read_output(cmd, input=None, keepends=False, **kw):
460 stdin = subprocess.PIPE
461 input = str_to_bytes(input)
466 errors = kw['errors']
468 p = subprocess.Popen(
469 tuple(str_to_bytes(w) for w in cmd),
470 stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw
472 (out, err) = p.communicate(input)
473 out = bytes_to_str(out, errors=errors)
476 raise CommandError(cmd, retcode)
478 out = out.rstrip('\n\r')
482 def read_git_lines(args, keepends=False, **kw):
483 """Return the lines output by Git command.
485 Return as single lines, with newlines stripped off."""
487 return read_git_output(args, keepends=True, **kw).splitlines(keepends)
490 def git_rev_list_ish(cmd, spec, args=None, **kw):
491 """Common functionality for invoking a 'git rev-list'-like command.
494 * cmd is the Git command to run, e.g., 'rev-list' or 'log'.
495 * spec is a list of revision arguments to pass to the named
496 command. If None, this function returns an empty list.
497 * args is a list of extra arguments passed to the named command.
498 * All other keyword arguments (if any) are passed to the
499 underlying read_git_lines() function.
501 Return the output of the Git command in the form of a list, one
502 entry per output line.
508 args = [cmd, '--stdin'] + args
509 spec_stdin = ''.join(s + '\n' for s in spec)
510 return read_git_lines(args, input=spec_stdin, **kw)
513 def git_rev_list(spec, **kw):
514 """Run 'git rev-list' with the given list of revision arguments.
516 See git_rev_list_ish() for parameter and return value
519 return git_rev_list_ish('rev-list', spec, **kw)
522 def git_log(spec, **kw):
523 """Run 'git log' with the given list of revision arguments.
525 See git_rev_list_ish() for parameter and return value
528 return git_rev_list_ish('log', spec, **kw)
531 def header_encode(text, header_name=None):
532 """Encode and line-wrap the value of an email header field."""
534 # Convert to unicode, if required.
535 if not isinstance(text, unicode):
536 text = unicode(text, 'utf-8')
543 return Header(text, header_name=header_name, charset=Charset(charset)).encode()
546 def addr_header_encode(text, header_name=None):
547 """Encode and line-wrap the value of an email header field containing
550 # Convert to unicode, if required.
551 if not isinstance(text, unicode):
552 text = unicode(text, 'utf-8')
555 formataddr((header_encode(name), emailaddr))
556 for name, emailaddr in getaddresses([text])
564 return Header(text, header_name=header_name, charset=Charset(charset)).encode()
567 class Config(object):
568 def __init__(self, section, git_config=None):
569 """Represent a section of the git configuration.
571 If git_config is specified, it is passed to "git config" in
572 the GIT_CONFIG environment variable, meaning that "git config"
573 will read the specified path rather than the Git default
576 self.section = section
578 self.env = os.environ.copy()
579 self.env['GIT_CONFIG'] = git_config
585 """Split NUL-terminated values."""
587 words = s.split('\0')
588 assert words[-1] == ''
592 def add_config_parameters(c):
593 """Add configuration parameters to Git.
595 c is either an str or a list of str, each element being of the
596 form 'var=val' or 'var', with the same syntax and meaning as
597 the argument of 'git -c var=val'.
599 if isinstance(c, str):
601 parameters = os.environ.get('GIT_CONFIG_PARAMETERS', '')
604 # git expects GIT_CONFIG_PARAMETERS to be of the form
605 # "'name1=value1' 'name2=value2' 'name3=value3'"
606 # including everything inside the double quotes (but not the double
607 # quotes themselves). Spacing is critical. Also, if a value contains
608 # a literal single quote that quote must be represented using the
609 # four character sequence: '\''
610 parameters += ' '.join("'" + x.replace("'", "'\\''") + "'" for x in c)
611 os.environ['GIT_CONFIG_PARAMETERS'] = parameters
613 def get(self, name, default=None):
615 values = self._split(read_git_output(
616 ['config', '--get', '--null', '%s.%s' % (self.section, name)],
617 env=self.env, keepends=True,
619 assert len(values) == 1
624 def get_bool(self, name, default=None):
626 value = read_git_output(
627 ['config', '--get', '--bool', '%s.%s' % (self.section, name)],
632 return value == 'true'
634 def get_all(self, name, default=None):
635 """Read a (possibly multivalued) setting from the configuration.
637 Return the result as a list of values, or default if the name
641 return self._split(read_git_output(
642 ['config', '--get-all', '--null', '%s.%s' % (self.section, name)],
643 env=self.env, keepends=True,
646 t, e, traceback = sys.exc_info()
648 # "the section or key is invalid"; i.e., there is no
649 # value for the specified key.
654 def set(self, name, value):
656 ['config', '%s.%s' % (self.section, name), value],
660 def add(self, name, value):
662 ['config', '--add', '%s.%s' % (self.section, name), value],
666 def __contains__(self, name):
667 return self.get_all(name, default=None) is not None
669 # We don't use this method anymore internally, but keep it here in
670 # case somebody is calling it from their own code:
671 def has_key(self, name):
674 def unset_all(self, name):
677 ['config', '--unset-all', '%s.%s' % (self.section, name)],
681 t, e, traceback = sys.exc_info()
683 # The name doesn't exist, which is what we wanted anyway...
688 def set_recipients(self, name, value):
690 for pair in getaddresses([value]):
691 self.add(name, formataddr(pair))
694 def generate_summaries(*log_args):
695 """Generate a brief summary for each revision requested.
697 log_args are strings that will be passed directly to "git log" as
698 revision selectors. Iterate over (sha1_short, subject) for each
699 commit specified by log_args (subject is the first line of the
700 commit message as a string without EOLs)."""
703 'log', '--abbrev', '--format=%h %s',
704 ] + list(log_args) + ['--']
705 for line in read_git_lines(cmd):
706 yield tuple(line.split(' ', 1))
709 def limit_lines(lines, max_lines):
710 for (index, line) in enumerate(lines):
711 if index < max_lines:
714 if index >= max_lines:
715 yield '... %d lines suppressed ...\n' % (index + 1 - max_lines,)
718 def limit_linelength(lines, max_linelength):
720 # Don't forget that lines always include a trailing newline.
721 if len(line) > max_linelength + 1:
722 line = line[:max_linelength - 7] + ' [...]\n'
726 class CommitSet(object):
727 """A (constant) set of object names.
729 The set should be initialized with full SHA1 object names. The
730 __contains__() method returns True iff its argument is an
731 abbreviation of any the names in the set."""
733 def __init__(self, names):
734 self._names = sorted(names)
737 return len(self._names)
739 def __contains__(self, sha1_abbrev):
740 """Return True iff this set contains sha1_abbrev (which might be abbreviated)."""
742 i = bisect.bisect_left(self._names, sha1_abbrev)
743 return i < len(self) and self._names[i].startswith(sha1_abbrev)
746 class GitObject(object):
747 def __init__(self, sha1, type=None):
749 self.sha1 = self.type = self.commit_sha1 = None
752 self.type = type or read_git_output(['cat-file', '-t', self.sha1])
754 if self.type == 'commit':
755 self.commit_sha1 = self.sha1
756 elif self.type == 'tag':
758 self.commit_sha1 = read_git_output(
759 ['rev-parse', '--verify', '%s^0' % (self.sha1,)]
762 # Cannot deref tag to determine commit_sha1
763 self.commit_sha1 = None
765 self.commit_sha1 = None
767 self.short = read_git_output(['rev-parse', '--short', sha1])
769 def get_summary(self):
770 """Return (sha1_short, subject) for this commit."""
773 raise ValueError('Empty commit has no summary')
775 return next(iter(generate_summaries('--no-walk', self.sha1)))
777 def __eq__(self, other):
778 return isinstance(other, GitObject) and self.sha1 == other.sha1
780 def __ne__(self, other):
781 return not self == other
784 return hash(self.sha1)
786 def __nonzero__(self):
787 return bool(self.sha1)
790 """Python 2 backward compatibility"""
791 return self.__nonzero__()
794 return self.sha1 or ZEROS
797 class Change(object):
798 """A Change that has been made to the Git repository.
800 Abstract class from which both Revisions and ReferenceChanges are
801 derived. A Change knows how to generate a notification email
802 describing itself."""
804 def __init__(self, environment):
805 self.environment = environment
807 self._contains_html_diff = False
809 def _contains_diff(self):
810 # We do contain a diff, should it be rendered in HTML?
811 if self.environment.commit_email_format == "html":
812 self._contains_html_diff = True
814 def _compute_values(self):
815 """Return a dictionary {keyword: expansion} for this Change.
817 Derived classes overload this method to add more entries to
818 the return value. This method is used internally by
819 get_values(). The return value should always be a new
822 values = self.environment.get_values()
823 fromaddr = self.environment.get_fromaddr(change=self)
824 if fromaddr is not None:
825 values['fromaddr'] = fromaddr
826 values['multimail_version'] = get_version()
829 # Aliases usable in template strings. Tuple of pairs (destination,
835 def get_values(self, **extra_values):
836 """Return a dictionary {keyword: expansion} for this Change.
838 Return a dictionary mapping keywords to the values that they
839 should be expanded to for this Change (used when interpolating
840 template strings). If any keyword arguments are supplied, add
841 those to the return value as well. The return value is always
844 if self._values is None:
845 self._values = self._compute_values()
847 values = self._values.copy()
849 values.update(extra_values)
851 for alias, val in self.VALUES_ALIAS:
852 values[alias] = values[val]
855 def expand(self, template, **extra_values):
858 Expand the template (which should be a string) using string
859 interpolation of the values for this Change. If any keyword
860 arguments are provided, also include those in the keywords
861 available for interpolation."""
863 return template % self.get_values(**extra_values)
865 def expand_lines(self, template, html_escape_val=False, **extra_values):
866 """Break template into lines and expand each line."""
868 values = self.get_values(**extra_values)
871 if is_string(values[k]):
872 values[k] = html_escape(values[k])
873 for line in template.splitlines(True):
876 def expand_header_lines(self, template, **extra_values):
877 """Break template into lines and expand each line as an RFC 2822 header.
879 Encode values and split up lines that are too long. Silently
880 skip lines that contain references to unknown variables."""
882 values = self.get_values(**extra_values)
883 if self._contains_html_diff:
884 self._content_type = 'html'
886 self._content_type = 'plain'
887 values['contenttype'] = self._content_type
889 for line in template.splitlines():
890 (name, value) = line.split(': ', 1)
893 value = value % values
895 t, e, traceback = sys.exc_info()
897 self.environment.log_warning(
898 'Warning: unknown variable %r in the following line; line skipped:\n'
903 if name.lower() in ADDR_HEADERS:
904 value = addr_header_encode(value, name)
906 value = header_encode(value, name)
907 for splitline in ('%s: %s\n' % (name, value)).splitlines(True):
910 def generate_email_header(self):
911 """Generate the RFC 2822 email headers for this Change, a line at a time.
913 The output should not include the trailing blank line."""
915 raise NotImplementedError()
917 def generate_browse_link(self, base_url):
918 """Generate a link to an online repository browser."""
921 def generate_email_intro(self, html_escape_val=False):
922 """Generate the email intro for this Change, a line at a time.
924 The output will be used as the standard boilerplate at the top
925 of the email body."""
927 raise NotImplementedError()
929 def generate_email_body(self, push):
930 """Generate the main part of the email body, a line at a time.
932 The text in the body might be truncated after a specified
933 number of lines (see multimailhook.emailmaxlines)."""
935 raise NotImplementedError()
937 def generate_email_footer(self, html_escape_val):
938 """Generate the footer of the email, a line at a time.
940 The footer is always included, irrespective of
941 multimailhook.emailmaxlines."""
943 raise NotImplementedError()
945 def _wrap_for_html(self, lines):
946 """Wrap the lines in HTML <pre> tag when using HTML format.
948 Escape special HTML characters and add <pre> and </pre> tags around
949 the given lines if we should be generating HTML as indicated by
950 self._contains_html_diff being set to true.
952 if self._contains_html_diff:
953 yield "<pre style='margin:0'>\n"
956 yield html_escape(line)
963 def generate_email(self, push, body_filter=None, extra_header_values={}):
964 """Generate an email describing this change.
966 Iterate over the lines (including the header lines) of an
967 email describing this change. If body_filter is not None,
968 then use it to filter the lines that are intended for the
971 The extra_header_values field is received as a dict and not as
972 **kwargs, to allow passing other keyword arguments in the
973 future (e.g. passing extra values to generate_email_intro()"""
975 for line in self.generate_email_header(**extra_header_values):
978 html_escape_val = (self.environment.html_in_intro and
979 self._contains_html_diff)
980 intro = self.generate_email_intro(html_escape_val)
981 if not self.environment.html_in_intro:
982 intro = self._wrap_for_html(intro)
986 if self.environment.commitBrowseURL:
987 for line in self.generate_browse_link(self.environment.commitBrowseURL):
990 body = self.generate_email_body(push)
991 if body_filter is not None:
992 body = body_filter(body)
995 if self._contains_html_diff:
996 # "white-space: pre" is the default, but we need to
997 # specify it again in case the message is viewed in a
998 # webmail which wraps it in an element setting white-space
999 # to something else (Zimbra does this and sets
1000 # white-space: pre-line).
1001 yield '<pre style="white-space: pre; background: #F8F8F8">'
1003 if self._contains_html_diff:
1004 # This is very, very naive. It would be much better to really
1005 # parse the diff, i.e. look at how many lines do we have in
1006 # the hunk headers instead of blindly highlighting everything
1007 # that looks like it might be part of a diff.
1010 if line.startswith('--- a/'):
1013 elif line.startswith('diff ') or line.startswith('index '):
1017 if line.startswith('+++ '):
1019 elif line.startswith('@@'):
1021 elif line.startswith('+'):
1023 elif line.startswith('-'):
1025 elif line.startswith('commit '):
1027 elif line.startswith(' '):
1030 # Chop the trailing LF, we don't want it inside <pre>.
1031 line = html_escape(line[:-1])
1033 if bgcolor or fgcolor:
1034 style = 'display:block; white-space:pre;'
1036 style += 'background:#' + bgcolor + ';'
1038 style += 'color:#' + fgcolor + ';'
1039 # Use a <span style='display:block> to color the
1040 # whole line. The newline must be inside the span
1041 # to display properly both in Firefox and in
1042 # text-based browser.
1043 line = "<span style='%s'>%s\n</span>" % (style, line)
1048 if self._contains_html_diff:
1050 html_escape_val = (self.environment.html_in_footer and
1051 self._contains_html_diff)
1052 footer = self.generate_email_footer(html_escape_val)
1053 if not self.environment.html_in_footer:
1054 footer = self._wrap_for_html(footer)
1058 def get_specific_fromaddr(self):
1059 """For kinds of Changes which specify it, return the kind-specific
1060 From address to use."""
1064 class Revision(Change):
1065 """A Change consisting of a single git commit."""
1067 CC_RE = re.compile(r'^\s*C[Cc]:\s*(?P<to>[^#]+@[^\s#]*)\s*(#.*)?$')
1069 def __init__(self, reference_change, rev, num, tot):
1070 Change.__init__(self, reference_change.environment)
1071 self.reference_change = reference_change
1073 self.change_type = self.reference_change.change_type
1074 self.refname = self.reference_change.refname
1077 self.author = read_git_output(['log', '--no-walk', '--format=%aN <%aE>', self.rev.sha1])
1078 self.recipients = self.environment.get_revision_recipients(self)
1080 # -s is short for --no-patch, but -s works on older git's (e.g. 1.7)
1081 self.parents = read_git_lines(['show', '-s', '--format=%P',
1082 self.rev.sha1])[0].split()
1084 self.cc_recipients = ''
1085 if self.environment.get_scancommitforcc():
1086 self.cc_recipients = ', '.join(to.strip() for to in self._cc_recipients())
1087 if self.cc_recipients:
1088 self.environment.log_msg(
1089 'Add %s to CC for %s' % (self.cc_recipients, self.rev.sha1))
1091 def _cc_recipients(self):
1093 message = read_git_output(['log', '--no-walk', '--format=%b', self.rev.sha1])
1094 lines = message.strip().split('\n')
1096 m = re.match(self.CC_RE, line)
1098 cc_recipients.append(m.group('to'))
1100 return cc_recipients
1102 def _compute_values(self):
1103 values = Change._compute_values(self)
1105 oneline = read_git_output(
1106 ['log', '--format=%s', '--no-walk', self.rev.sha1]
1109 max_subject_length = self.environment.get_max_subject_length()
1110 if max_subject_length > 0 and len(oneline) > max_subject_length:
1111 oneline = oneline[:max_subject_length - 6] + ' [...]'
1113 values['rev'] = self.rev.sha1
1114 values['parents'] = ' '.join(self.parents)
1115 values['rev_short'] = self.rev.short
1116 values['change_type'] = self.change_type
1117 values['refname'] = self.refname
1118 values['newrev'] = self.rev.sha1
1119 values['short_refname'] = self.reference_change.short_refname
1120 values['refname_type'] = self.reference_change.refname_type
1121 values['reply_to_msgid'] = self.reference_change.msgid
1122 values['thread_index'] = self.reference_change.thread_index
1123 values['num'] = self.num
1124 values['tot'] = self.tot
1125 values['recipients'] = self.recipients
1126 if self.cc_recipients:
1127 values['cc_recipients'] = self.cc_recipients
1128 values['oneline'] = oneline
1129 values['author'] = self.author
1131 reply_to = self.environment.get_reply_to_commit(self)
1133 values['reply_to'] = reply_to
1137 def generate_email_header(self, **extra_values):
1138 for line in self.expand_header_lines(
1139 REVISION_HEADER_TEMPLATE, **extra_values
1143 def generate_browse_link(self, base_url):
1144 if '%(' not in base_url:
1145 base_url += '%(id)s'
1146 url = "".join(self.expand_lines(base_url))
1147 if self._content_type == 'html':
1148 for line in self.expand_lines(LINK_HTML_TEMPLATE,
1149 html_escape_val=True,
1152 elif self._content_type == 'plain':
1153 for line in self.expand_lines(LINK_TEXT_TEMPLATE,
1154 html_escape_val=False,
1158 raise NotImplementedError("Content-type %s unsupported. Please report it as a bug.")
1160 def generate_email_intro(self, html_escape_val=False):
1161 for line in self.expand_lines(REVISION_INTRO_TEMPLATE,
1162 html_escape_val=html_escape_val):
1165 def generate_email_body(self, push):
1166 """Show this revision."""
1168 for line in read_git_lines(
1169 ['log'] + self.environment.commitlogopts + ['-1', self.rev.sha1],
1172 if line.startswith('Date: ') and self.environment.date_substitute:
1173 yield self.environment.date_substitute + line[len('Date: '):]
1177 def generate_email_footer(self, html_escape_val):
1178 return self.expand_lines(REVISION_FOOTER_TEMPLATE,
1179 html_escape_val=html_escape_val)
1181 def generate_email(self, push, body_filter=None, extra_header_values={}):
1182 self._contains_diff()
1183 return Change.generate_email(self, push, body_filter, extra_header_values)
1185 def get_specific_fromaddr(self):
1186 return self.environment.from_commit
1189 class ReferenceChange(Change):
1190 """A Change to a Git reference.
1192 An abstract class representing a create, update, or delete of a
1193 Git reference. Derived classes handle specific types of reference
1194 (e.g., tags vs. branches). These classes generate the main
1195 reference change email summarizing the reference change and
1196 whether it caused any any commits to be added or removed.
1198 ReferenceChange objects are usually created using the static
1199 create() method, which has the logic to decide which derived class
1202 REF_RE = re.compile(r'^refs\/(?P<area>[^\/]+)\/(?P<shortname>.*)$')
1205 def create(environment, oldrev, newrev, refname):
1206 """Return a ReferenceChange object representing the change.
1208 Return an object that represents the type of change that is being
1209 made. oldrev and newrev should be SHA1s or ZEROS."""
1211 old = GitObject(oldrev)
1212 new = GitObject(newrev)
1215 # The revision type tells us what type the commit is, combined with
1216 # the location of the ref we can decide between
1221 m = ReferenceChange.REF_RE.match(refname)
1223 area = m.group('area')
1224 short_refname = m.group('shortname')
1227 short_refname = refname
1229 if rev.type == 'tag':
1231 klass = AnnotatedTagChange
1232 elif rev.type == 'commit':
1234 # Non-annotated tag:
1235 klass = NonAnnotatedTagChange
1236 elif area == 'heads':
1238 klass = BranchChange
1239 elif area == 'remotes':
1241 environment.log_warning(
1242 '*** Push-update of tracking branch %r\n'
1243 '*** - incomplete email generated.'
1246 klass = OtherReferenceChange
1248 # Some other reference namespace:
1249 environment.log_warning(
1250 '*** Push-update of strange reference %r\n'
1251 '*** - incomplete email generated.'
1254 klass = OtherReferenceChange
1256 # Anything else (is there anything else?)
1257 environment.log_warning(
1258 '*** Unknown type of update to %r (%s)\n'
1259 '*** - incomplete email generated.'
1260 % (refname, rev.type,)
1262 klass = OtherReferenceChange
1266 refname=refname, short_refname=short_refname,
1267 old=old, new=new, rev=rev,
1271 def make_thread_index():
1272 """Return a string appropriate for the Thread-Index header,
1273 needed by MS Outlook to get threading right.
1275 The format is (base64-encoded):
1277 - 5 bytes encode a date (hardcoded here)
1278 - 16 bytes for a globally unique identifier
1280 FIXME: Unfortunately, even with the Thread-Index field, MS
1281 Outlook doesn't seem to do the threading reliably (see
1282 https://github.com/git-multimail/git-multimail/pull/194).
1284 thread_index = b'\x01\x00\x00\x12\x34\x56' + uuid.uuid4().bytes
1285 return base64.standard_b64encode(thread_index).decode('ascii')
1287 def __init__(self, environment, refname, short_refname, old, new, rev):
1288 Change.__init__(self, environment)
1289 self.change_type = {
1290 (False, True): 'create',
1291 (True, True): 'update',
1292 (True, False): 'delete',
1293 }[bool(old), bool(new)]
1294 self.refname = refname
1295 self.short_refname = short_refname
1299 self.msgid = make_msgid()
1300 self.thread_index = self.make_thread_index()
1301 self.diffopts = environment.diffopts
1302 self.graphopts = environment.graphopts
1303 self.logopts = environment.logopts
1304 self.commitlogopts = environment.commitlogopts
1305 self.showgraph = environment.refchange_showgraph
1306 self.showlog = environment.refchange_showlog
1308 self.header_template = REFCHANGE_HEADER_TEMPLATE
1309 self.intro_template = REFCHANGE_INTRO_TEMPLATE
1310 self.footer_template = FOOTER_TEMPLATE
1312 def _compute_values(self):
1313 values = Change._compute_values(self)
1315 values['change_type'] = self.change_type
1316 values['refname_type'] = self.refname_type
1317 values['refname'] = self.refname
1318 values['short_refname'] = self.short_refname
1319 values['msgid'] = self.msgid
1320 values['thread_index'] = self.thread_index
1321 values['recipients'] = self.recipients
1322 values['oldrev'] = str(self.old)
1323 values['oldrev_short'] = self.old.short
1324 values['newrev'] = str(self.new)
1325 values['newrev_short'] = self.new.short
1328 values['oldrev_type'] = self.old.type
1330 values['newrev_type'] = self.new.type
1332 reply_to = self.environment.get_reply_to_refchange(self)
1334 values['reply_to'] = reply_to
1338 def send_single_combined_email(self, known_added_sha1s):
1339 """Determine if a combined refchange/revision email should be sent
1341 If there is only a single new (non-merge) commit added by a
1342 change, it is useful to combine the ReferenceChange and
1343 Revision emails into one. In such a case, return the single
1344 revision; otherwise, return None.
1346 This method is overridden in BranchChange."""
1350 def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):
1351 """Generate an email describing this change AND specified revision.
1353 Iterate over the lines (including the header lines) of an
1354 email describing this change. If body_filter is not None,
1355 then use it to filter the lines that are intended for the
1358 The extra_header_values field is received as a dict and not as
1359 **kwargs, to allow passing other keyword arguments in the
1360 future (e.g. passing extra values to generate_email_intro()
1362 This method is overridden in BranchChange."""
1364 raise NotImplementedError
1366 def get_subject(self):
1368 'create': REF_CREATED_SUBJECT_TEMPLATE,
1369 'update': REF_UPDATED_SUBJECT_TEMPLATE,
1370 'delete': REF_DELETED_SUBJECT_TEMPLATE,
1372 return self.expand(template)
1374 def generate_email_header(self, **extra_values):
1375 if 'subject' not in extra_values:
1376 extra_values['subject'] = self.get_subject()
1378 for line in self.expand_header_lines(
1379 self.header_template, **extra_values
1383 def generate_email_intro(self, html_escape_val=False):
1384 for line in self.expand_lines(self.intro_template,
1385 html_escape_val=html_escape_val):
1388 def generate_email_body(self, push):
1389 """Call the appropriate body-generation routine.
1391 Call one of generate_create_summary() /
1392 generate_update_summary() / generate_delete_summary()."""
1395 'create': self.generate_create_summary,
1396 'delete': self.generate_delete_summary,
1397 'update': self.generate_update_summary,
1398 }[self.change_type](push)
1399 for line in change_summary:
1402 for line in self.generate_revision_change_summary(push):
1405 def generate_email_footer(self, html_escape_val):
1406 return self.expand_lines(self.footer_template,
1407 html_escape_val=html_escape_val)
1409 def generate_revision_change_graph(self, push):
1411 args = ['--graph'] + self.graphopts
1412 for newold in ('new', 'old'):
1414 spec = push.get_commits_spec(newold, self)
1415 for line in git_log(spec, args=args, keepends=True):
1419 yield 'Graph of %s commits:\n\n' % (
1420 {'new': 'new', 'old': 'discarded'}[newold],)
1425 def generate_revision_change_log(self, new_commits_list):
1428 yield 'Detailed log of new commits:\n\n'
1429 for line in read_git_lines(
1430 ['log', '--no-walk'] +
1438 def generate_new_revision_summary(self, tot, new_commits_list, push):
1439 for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot):
1441 for line in self.generate_revision_change_graph(push):
1443 for line in self.generate_revision_change_log(new_commits_list):
1446 def generate_revision_change_summary(self, push):
1447 """Generate a summary of the revisions added/removed by this change."""
1449 if self.new.commit_sha1 and not self.old.commit_sha1:
1450 # A new reference was created. List the new revisions
1451 # brought by the new reference (i.e., those revisions that
1452 # were not in the repository before this reference
1454 sha1s = list(push.get_new_commits(self))
1458 Revision(self, GitObject(sha1), num=i + 1, tot=tot)
1459 for (i, sha1) in enumerate(sha1s)
1463 yield self.expand('This %(refname_type)s includes the following new commits:\n')
1465 for r in new_revisions:
1466 (sha1, subject) = r.rev.get_summary()
1468 BRIEF_SUMMARY_TEMPLATE, action='new', text=subject,
1471 for line in self.generate_new_revision_summary(
1472 tot, [r.rev.sha1 for r in new_revisions], push):
1475 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
1478 elif self.new.commit_sha1 and self.old.commit_sha1:
1479 # A reference was changed to point at a different commit.
1480 # List the revisions that were removed and/or added *from
1481 # that reference* by this reference change, along with a
1482 # diff between the trees for its old and new values.
1484 # List of the revisions that were added to the branch by
1485 # this update. Note this list can include revisions that
1486 # have already had notification emails; we want such
1487 # revisions in the summary even though we will not send
1488 # new notification emails for them.
1489 adds = list(generate_summaries(
1490 '--topo-order', '--reverse', '%s..%s'
1491 % (self.old.commit_sha1, self.new.commit_sha1,)
1494 # List of the revisions that were removed from the branch
1495 # by this update. This will be empty except for
1496 # non-fast-forward updates.
1497 discards = list(generate_summaries(
1498 '%s..%s' % (self.new.commit_sha1, self.old.commit_sha1,)
1502 new_commits_list = push.get_new_commits(self)
1504 new_commits_list = []
1505 new_commits = CommitSet(new_commits_list)
1508 discarded_commits = CommitSet(push.get_discarded_commits(self))
1510 discarded_commits = CommitSet([])
1512 if discards and adds:
1513 for (sha1, subject) in discards:
1514 if sha1 in discarded_commits:
1519 BRIEF_SUMMARY_TEMPLATE, action=action,
1520 rev_short=sha1, text=subject,
1522 for (sha1, subject) in adds:
1523 if sha1 in new_commits:
1528 BRIEF_SUMMARY_TEMPLATE, action=action,
1529 rev_short=sha1, text=subject,
1532 for line in self.expand_lines(NON_FF_TEMPLATE):
1536 for (sha1, subject) in discards:
1537 if sha1 in discarded_commits:
1542 BRIEF_SUMMARY_TEMPLATE, action=action,
1543 rev_short=sha1, text=subject,
1546 for line in self.expand_lines(REWIND_ONLY_TEMPLATE):
1550 (sha1, subject) = self.old.get_summary()
1552 BRIEF_SUMMARY_TEMPLATE, action='from',
1553 rev_short=sha1, text=subject,
1555 for (sha1, subject) in adds:
1556 if sha1 in new_commits:
1561 BRIEF_SUMMARY_TEMPLATE, action=action,
1562 rev_short=sha1, text=subject,
1568 for line in self.generate_new_revision_summary(
1569 len(new_commits), new_commits_list, push):
1572 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
1574 for line in self.generate_revision_change_graph(push):
1577 # The diffstat is shown from the old revision to the new
1578 # revision. This is to show the truth of what happened in
1579 # this change. There's no point showing the stat from the
1580 # base to the new revision because the base is effectively a
1581 # random revision at this point - the user will be interested
1582 # in what this revision changed - including the undoing of
1583 # previous revisions in the case of non-fast-forward updates.
1585 yield 'Summary of changes:\n'
1586 for line in read_git_lines(
1589 ['%s..%s' % (self.old.commit_sha1, self.new.commit_sha1,)],
1594 elif self.old.commit_sha1 and not self.new.commit_sha1:
1595 # A reference was deleted. List the revisions that were
1596 # removed from the repository by this reference change.
1598 sha1s = list(push.get_discarded_commits(self))
1600 discarded_revisions = [
1601 Revision(self, GitObject(sha1), num=i + 1, tot=tot)
1602 for (i, sha1) in enumerate(sha1s)
1605 if discarded_revisions:
1606 for line in self.expand_lines(DISCARDED_REVISIONS_TEMPLATE):
1609 for r in discarded_revisions:
1610 (sha1, subject) = r.rev.get_summary()
1612 BRIEF_SUMMARY_TEMPLATE, action='discard', text=subject,
1614 for line in self.generate_revision_change_graph(push):
1617 for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE):
1620 elif not self.old.commit_sha1 and not self.new.commit_sha1:
1621 for line in self.expand_lines(NON_COMMIT_UPDATE_TEMPLATE):
1624 def generate_create_summary(self, push):
1625 """Called for the creation of a reference."""
1627 # This is a new reference and so oldrev is not valid
1628 (sha1, subject) = self.new.get_summary()
1630 BRIEF_SUMMARY_TEMPLATE, action='at',
1631 rev_short=sha1, text=subject,
1635 def generate_update_summary(self, push):
1636 """Called for the change of a pre-existing branch."""
1640 def generate_delete_summary(self, push):
1641 """Called for the deletion of any type of reference."""
1643 (sha1, subject) = self.old.get_summary()
1645 BRIEF_SUMMARY_TEMPLATE, action='was',
1646 rev_short=sha1, text=subject,
1650 def get_specific_fromaddr(self):
1651 return self.environment.from_refchange
1654 class BranchChange(ReferenceChange):
1655 refname_type = 'branch'
1657 def __init__(self, environment, refname, short_refname, old, new, rev):
1658 ReferenceChange.__init__(
1660 refname=refname, short_refname=short_refname,
1661 old=old, new=new, rev=rev,
1663 self.recipients = environment.get_refchange_recipients(self)
1664 self._single_revision = None
1666 def send_single_combined_email(self, known_added_sha1s):
1667 if not self.environment.combine_when_single_commit:
1670 # In the sadly-all-too-frequent usecase of people pushing only
1671 # one of their commits at a time to a repository, users feel
1672 # the reference change summary emails are noise rather than
1673 # important signal. This is because, in this particular
1674 # usecase, there is a reference change summary email for each
1675 # new commit, and all these summaries do is point out that
1676 # there is one new commit (which can readily be inferred by
1677 # the existence of the individual revision email that is also
1678 # sent). In such cases, our users prefer there to be a combined
1679 # reference change summary/new revision email.
1681 # So, if the change is an update and it doesn't discard any
1682 # commits, and it adds exactly one non-merge commit (gerrit
1683 # forces a workflow where every commit is individually merged
1684 # and the git-multimail hook fired off for just this one
1685 # change), then we send a combined refchange/revision email.
1687 # If this change is a reference update that doesn't discard
1689 if self.change_type != 'update':
1693 ['merge-base', self.old.sha1, self.new.sha1]
1694 ) != [self.old.sha1]:
1697 # Check if this update introduced exactly one non-merge
1700 def split_line(line):
1701 """Split line into (sha1, [parent,...])."""
1703 words = line.split()
1704 return (words[0], words[1:])
1706 # Get the new commits introduced by the push as a list of
1707 # (sha1, [parent,...])
1710 for line in read_git_lines(
1712 'log', '-3', '--format=%H %P',
1713 '%s..%s' % (self.old.sha1, self.new.sha1),
1721 # If the newest commit is a merge, save it for a later check
1722 # but otherwise ignore it
1724 tot = len(new_commits)
1725 if len(new_commits[0][1]) > 1:
1726 merge = new_commits[0][0]
1729 # Our primary check: we can't combine if more than one commit
1730 # is introduced. We also currently only combine if the new
1731 # commit is a non-merge commit, though it may make sense to
1732 # combine if it is a merge as well.
1734 len(new_commits) == 1 and
1735 len(new_commits[0][1]) == 1 and
1736 new_commits[0][0] in known_added_sha1s
1740 # We do not want to combine revision and refchange emails if
1741 # those go to separate locations.
1742 rev = Revision(self, GitObject(new_commits[0][0]), 1, tot)
1743 if rev.recipients != self.recipients:
1746 # We ignored the newest commit if it was just a merge of the one
1747 # commit being introduced. But we don't want to ignore that
1748 # merge commit it it involved conflict resolutions. Check that.
1749 if merge and merge != read_git_output(['diff-tree', '--cc', merge]):
1752 # We can combine the refchange and one new revision emails
1753 # into one. Return the Revision that a combined email should
1756 except CommandError:
1757 # Cannot determine number of commits in old..new or new..old;
1758 # don't combine reference/revision emails:
1761 def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):
1762 values = revision.get_values()
1763 if extra_header_values:
1764 values.update(extra_header_values)
1765 if 'subject' not in extra_header_values:
1766 values['subject'] = self.expand(COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE, **values)
1768 self._single_revision = revision
1769 self._contains_diff()
1770 self.header_template = COMBINED_HEADER_TEMPLATE
1771 self.intro_template = COMBINED_INTRO_TEMPLATE
1772 self.footer_template = COMBINED_FOOTER_TEMPLATE
1774 def revision_gen_link(base_url):
1775 # revision is used only to generate the body, and
1776 # _content_type is set while generating headers. Get it
1777 # from the BranchChange object.
1778 revision._content_type = self._content_type
1779 return revision.generate_browse_link(base_url)
1780 self.generate_browse_link = revision_gen_link
1781 for line in self.generate_email(push, body_filter, values):
1784 def generate_email_body(self, push):
1785 '''Call the appropriate body generation routine.
1787 If this is a combined refchange/revision email, the special logic
1788 for handling this combined email comes from this function. For
1789 other cases, we just use the normal handling.'''
1791 # If self._single_revision isn't set; don't override
1792 if not self._single_revision:
1793 for line in super(BranchChange, self).generate_email_body(push):
1797 # This is a combined refchange/revision email; we first provide
1798 # some info from the refchange portion, and then call the revision
1799 # generate_email_body function to handle the revision portion.
1800 adds = list(generate_summaries(
1801 '--topo-order', '--reverse', '%s..%s'
1802 % (self.old.commit_sha1, self.new.commit_sha1,)
1805 yield self.expand("The following commit(s) were added to %(refname)s by this push:\n")
1806 for (sha1, subject) in adds:
1808 BRIEF_SUMMARY_TEMPLATE, action='new',
1809 rev_short=sha1, text=subject,
1812 yield self._single_revision.rev.short + " is described below\n"
1815 for line in self._single_revision.generate_email_body(push):
1819 class AnnotatedTagChange(ReferenceChange):
1820 refname_type = 'annotated tag'
1822 def __init__(self, environment, refname, short_refname, old, new, rev):
1823 ReferenceChange.__init__(
1825 refname=refname, short_refname=short_refname,
1826 old=old, new=new, rev=rev,
1828 self.recipients = environment.get_announce_recipients(self)
1829 self.show_shortlog = environment.announce_show_shortlog
1831 ANNOTATED_TAG_FORMAT = (
1838 def describe_tag(self, push):
1839 """Describe the new value of an annotated tag."""
1841 # Use git for-each-ref to pull out the individual fields from
1843 [tagobject, tagtype, tagger, tagged] = read_git_lines(
1844 ['for-each-ref', '--format=%s' % (self.ANNOTATED_TAG_FORMAT,), self.refname],
1848 BRIEF_SUMMARY_TEMPLATE, action='tagging',
1849 rev_short=tagobject, text='(%s)' % (tagtype,),
1851 if tagtype == 'commit':
1852 # If the tagged object is a commit, then we assume this is a
1853 # release, and so we calculate which tag this tag is
1856 prevtag = read_git_output(['describe', '--abbrev=0', '%s^' % (self.new,)])
1857 except CommandError:
1860 yield ' replaces %s\n' % (prevtag,)
1863 yield ' length %s bytes\n' % (read_git_output(['cat-file', '-s', tagobject]),)
1865 yield ' by %s\n' % (tagger,)
1866 yield ' on %s\n' % (tagged,)
1869 # Show the content of the tag message; this might contain a
1870 # change log or release notes so is worth displaying.
1872 contents = list(read_git_lines(['cat-file', 'tag', self.new.sha1], keepends=True))
1873 contents = contents[contents.index('\n') + 1:]
1874 if contents and contents[-1][-1:] != '\n':
1875 contents.append('\n')
1876 for line in contents:
1879 if self.show_shortlog and tagtype == 'commit':
1880 # Only commit tags make sense to have rev-list operations
1884 # Show changes since the previous release
1885 revlist = read_git_output(
1886 ['rev-list', '--pretty=short', '%s..%s' % (prevtag, self.new,)],
1890 # No previous tag, show all the changes since time
1892 revlist = read_git_output(
1893 ['rev-list', '--pretty=short', '%s' % (self.new,)],
1896 for line in read_git_lines(['shortlog'], input=revlist, keepends=True):
1902 def generate_create_summary(self, push):
1903 """Called for the creation of an annotated tag."""
1905 for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1908 for line in self.describe_tag(push):
1911 def generate_update_summary(self, push):
1912 """Called for the update of an annotated tag.
1914 This is probably a rare event and may not even be allowed."""
1916 for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1919 for line in self.describe_tag(push):
1922 def generate_delete_summary(self, push):
1923 """Called when a non-annotated reference is updated."""
1925 for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1928 yield self.expand(' tag was %(oldrev_short)s\n')
1932 class NonAnnotatedTagChange(ReferenceChange):
1933 refname_type = 'tag'
1935 def __init__(self, environment, refname, short_refname, old, new, rev):
1936 ReferenceChange.__init__(
1938 refname=refname, short_refname=short_refname,
1939 old=old, new=new, rev=rev,
1941 self.recipients = environment.get_refchange_recipients(self)
1943 def generate_create_summary(self, push):
1944 """Called for the creation of an annotated tag."""
1946 for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1949 def generate_update_summary(self, push):
1950 """Called when a non-annotated reference is updated."""
1952 for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1955 def generate_delete_summary(self, push):
1956 """Called when a non-annotated reference is updated."""
1958 for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1961 for line in ReferenceChange.generate_delete_summary(self, push):
1965 class OtherReferenceChange(ReferenceChange):
1966 refname_type = 'reference'
1968 def __init__(self, environment, refname, short_refname, old, new, rev):
1969 # We use the full refname as short_refname, because otherwise
1970 # the full name of the reference would not be obvious from the
1971 # text of the email.
1972 ReferenceChange.__init__(
1974 refname=refname, short_refname=refname,
1975 old=old, new=new, rev=rev,
1977 self.recipients = environment.get_refchange_recipients(self)
1980 class Mailer(object):
1981 """An object that can send emails."""
1983 def __init__(self, environment):
1984 self.environment = environment
1989 def send(self, lines, to_addrs):
1990 """Send an email consisting of lines.
1992 lines must be an iterable over the lines constituting the
1993 header and body of the email. to_addrs is a list of recipient
1994 addresses (can be needed even if lines already contains a
1995 "To:" field). It can be either a string (comma-separated list
1996 of email addresses) or a Python list of individual email
2001 raise NotImplementedError()
2004 class SendMailer(Mailer):
2005 """Send emails using 'sendmail -oi -t'."""
2007 SENDMAIL_CANDIDATES = [
2008 '/usr/sbin/sendmail',
2009 '/usr/lib/sendmail',
2013 def find_sendmail():
2014 for path in SendMailer.SENDMAIL_CANDIDATES:
2015 if os.access(path, os.X_OK):
2018 raise ConfigurationException(
2019 'No sendmail executable found. '
2020 'Try setting multimailhook.sendmailCommand.'
2023 def __init__(self, environment, command=None, envelopesender=None):
2024 """Construct a SendMailer instance.
2026 command should be the command and arguments used to invoke
2027 sendmail, as a list of strings. If an envelopesender is
2028 provided, it will also be passed to the command, via '-f
2030 super(SendMailer, self).__init__(environment)
2032 self.command = command[:]
2034 self.command = [self.find_sendmail(), '-oi', '-t']
2037 self.command.extend(['-f', envelopesender])
2039 def send(self, lines, to_addrs):
2041 p = subprocess.Popen(self.command, stdin=subprocess.PIPE)
2043 self.environment.get_logger().error(
2044 '*** Cannot execute command: %s\n' % ' '.join(self.command) +
2045 '*** %s\n' % sys.exc_info()[1] +
2046 '*** Try setting multimailhook.mailer to "smtp"\n' +
2047 '*** to send emails without using the sendmail command.\n'
2051 lines = (str_to_bytes(line) for line in lines)
2052 p.stdin.writelines(lines)
2054 self.environment.get_logger().error(
2055 '*** Error while generating commit email\n'
2056 '*** - mail sending aborted.\n'
2058 if hasattr(p, 'terminate'):
2059 # subprocess.terminate() is not available in Python 2.4
2063 os.kill(p.pid, signal.SIGTERM)
2069 raise CommandError(self.command, retcode)
2072 class SMTPMailer(Mailer):
2073 """Send emails using Python's smtplib."""
2075 def __init__(self, environment,
2076 envelopesender, smtpserver,
2077 smtpservertimeout=10.0, smtpserverdebuglevel=0,
2078 smtpencryption='none',
2079 smtpuser='', smtppass='',
2082 super(SMTPMailer, self).__init__(environment)
2083 if not envelopesender:
2084 self.environment.get_logger().error(
2085 'fatal: git_multimail: cannot use SMTPMailer without a sender address.\n'
2086 'please set either multimailhook.envelopeSender or user.email\n'
2089 if smtpencryption == 'ssl' and not (smtpuser and smtppass):
2090 raise ConfigurationException(
2091 'Cannot use SMTPMailer with security option ssl '
2092 'without options username and password.'
2094 self.envelopesender = envelopesender
2095 self.smtpserver = smtpserver
2096 self.smtpservertimeout = smtpservertimeout
2097 self.smtpserverdebuglevel = smtpserverdebuglevel
2098 self.security = smtpencryption
2099 self.username = smtpuser
2100 self.password = smtppass
2101 self.smtpcacerts = smtpcacerts
2102 self.loggedin = False
2104 def call(klass, server, timeout):
2106 return klass(server, timeout=timeout)
2108 # Old Python versions do not have timeout= argument.
2109 return klass(server)
2110 if self.security == 'none':
2111 self.smtp = call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)
2112 elif self.security == 'ssl':
2113 if self.smtpcacerts:
2114 raise smtplib.SMTPException(
2115 "Checking certificate is not supported for ssl, prefer starttls"
2117 self.smtp = call(smtplib.SMTP_SSL, self.smtpserver, timeout=self.smtpservertimeout)
2118 elif self.security == 'tls':
2119 if 'ssl' not in sys.modules:
2120 self.environment.get_logger().error(
2121 '*** Your Python version does not have the ssl library installed\n'
2122 '*** smtpEncryption=tls is not available.\n'
2123 '*** Either upgrade Python to 2.6 or later\n'
2124 ' or use git_multimail.py version 1.2.\n')
2125 if ':' not in self.smtpserver:
2126 self.smtpserver += ':587' # default port for TLS
2127 self.smtp = call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)
2128 # start: ehlo + starttls
2131 # self.smtp.starttls()
2132 # with access to the ssl layer
2134 if not self.smtp.has_extn("starttls"):
2135 raise smtplib.SMTPException("STARTTLS extension not supported by server")
2136 resp, reply = self.smtp.docmd("STARTTLS")
2138 raise smtplib.SMTPException("Wrong answer to the STARTTLS command")
2139 if self.smtpcacerts:
2140 self.smtp.sock = ssl.wrap_socket(
2142 ca_certs=self.smtpcacerts,
2143 cert_reqs=ssl.CERT_REQUIRED
2146 self.smtp.sock = ssl.wrap_socket(
2148 cert_reqs=ssl.CERT_NONE
2150 self.environment.get_logger().error(
2151 '*** Warning, the server certificate is not verified (smtp) ***\n'
2152 '*** set the option smtpCACerts ***\n'
2154 if not hasattr(self.smtp.sock, "read"):
2155 # using httplib.FakeSocket with Python 2.5.x or earlier
2156 self.smtp.sock.read = self.smtp.sock.recv
2157 self.smtp.file = smtplib.SSLFakeFile(self.smtp.sock)
2158 self.smtp.helo_resp = None
2159 self.smtp.ehlo_resp = None
2160 self.smtp.esmtp_features = {}
2161 self.smtp.does_esmtp = 0
2162 # end: ehlo + starttls
2165 sys.stdout.write('*** Error: Control reached an invalid option. ***')
2167 if self.smtpserverdebuglevel > 0:
2169 "*** Setting debug on for SMTP server connection (%s) ***\n"
2170 % self.smtpserverdebuglevel)
2171 self.smtp.set_debuglevel(self.smtpserverdebuglevel)
2173 self.environment.get_logger().error(
2174 '*** Error establishing SMTP connection to %s ***\n'
2176 % (self.smtpserver, sys.exc_info()[1]))
2180 if hasattr(self, 'smtp'):
2187 def send(self, lines, to_addrs):
2189 if self.username or self.password:
2190 if not self.loggedin:
2191 self.smtp.login(self.username, self.password)
2192 self.loggedin = True
2193 msg = ''.join(lines)
2194 # turn comma-separated list into Python list if needed.
2195 if is_string(to_addrs):
2196 to_addrs = [email for (name, email) in getaddresses([to_addrs])]
2197 self.smtp.sendmail(self.envelopesender, to_addrs, msg)
2198 except socket.timeout:
2199 self.environment.get_logger().error(
2200 '*** Error sending email ***\n'
2201 '*** SMTP server timed out (timeout is %s)\n'
2202 % self.smtpservertimeout)
2203 except smtplib.SMTPResponseException:
2204 err = sys.exc_info()[1]
2205 self.environment.get_logger().error(
2206 '*** Error sending email ***\n'
2207 '*** Error %d: %s\n'
2208 % (err.smtp_code, bytes_to_str(err.smtp_error)))
2211 # delete the field before quit() so that in case of
2212 # error, self.smtp is deleted anyway.
2216 self.environment.get_logger().error(
2217 '*** Error closing the SMTP connection ***\n'
2218 '*** Exiting anyway ... ***\n'
2219 '*** %s\n' % sys.exc_info()[1])
2223 class OutputMailer(Mailer):
2224 """Write emails to an output stream, bracketed by lines of '=' characters.
2226 This is intended for debugging purposes."""
2228 SEPARATOR = '=' * 75 + '\n'
2230 def __init__(self, f, environment=None):
2231 super(OutputMailer, self).__init__(environment=environment)
2234 def send(self, lines, to_addrs):
2235 write_str(self.f, self.SEPARATOR)
2237 write_str(self.f, line)
2238 write_str(self.f, self.SEPARATOR)
2242 """Determine GIT_DIR.
2244 Determine GIT_DIR either from the GIT_DIR environment variable or
2245 from the working directory, using Git's usual rules."""
2248 return read_git_output(['rev-parse', '--git-dir'])
2249 except CommandError:
2250 sys.stderr.write('fatal: git_multimail: not in a git directory\n')
2254 class Environment(object):
2255 """Describes the environment in which the push is occurring.
2257 An Environment object encapsulates information about the local
2258 environment. For example, it knows how to determine:
2260 * the name of the repository to which the push occurred
2262 * what user did the push
2264 * what users want to be informed about various types of changes.
2266 An Environment object is expected to have the following methods:
2268 get_repo_shortname()
2270 Return a short name for the repository, for display
2275 Return the absolute path to the Git repository.
2279 Return a string that will be prefixed to every email's
2284 Return the username of the person who pushed the changes.
2285 This value is used in the email body to indicate who
2288 get_pusher_email() (may return None)
2290 Return the email address of the person who pushed the
2291 changes. The value should be a single RFC 2822 email
2292 address as a string; e.g., "Joe User <user@example.com>"
2293 if available, otherwise "user@example.com". If set, the
2294 value is used as the Reply-To address for refchange
2295 emails. If it is impossible to determine the pusher's
2296 email, this attribute should be set to None (in which case
2297 no Reply-To header will be output).
2301 Return the address to be used as the 'From' email address
2302 in the email envelope.
2304 get_fromaddr(change=None)
2306 Return the 'From' email address used in the email 'From:'
2307 headers. If the change is known when this function is
2308 called, it is passed in as the 'change' parameter. (May
2309 be a full RFC 2822 email address like 'Joe User
2310 <user@example.com>'.)
2314 Return the name and/or email of the repository
2315 administrator. This value is used in the footer as the
2316 person to whom requests to be removed from the
2317 notification list should be sent. Ideally, it should
2318 include a valid email address.
2320 get_reply_to_refchange()
2321 get_reply_to_commit()
2323 Return the address to use in the email "Reply-To" header,
2324 as a string. These can be an RFC 2822 email address, or
2325 None to omit the "Reply-To" header.
2326 get_reply_to_refchange() is used for refchange emails;
2327 get_reply_to_commit() is used for individual commit
2330 get_ref_filter_regex()
2332 Return a tuple -- a compiled regex, and a boolean indicating
2333 whether the regex picks refs to include (if False, the regex
2334 matches on refs to exclude).
2336 get_default_ref_ignore_regex()
2338 Return a regex that should be ignored for both what emails
2339 to send and when computing what commits are considered new
2340 to the repository. Default is "^refs/notes/".
2342 get_max_subject_length()
2344 Return an int giving the maximal length for the subject
2345 (git log --oneline).
2347 They should also define the following attributes:
2349 announce_show_shortlog (bool)
2351 True iff announce emails should include a shortlog.
2353 commit_email_format (string)
2355 If "html", generate commit emails in HTML instead of plain text
2358 html_in_intro (bool)
2359 html_in_footer (bool)
2361 When generating HTML emails, the introduction (respectively,
2362 the footer) will be HTML-escaped iff html_in_intro (respectively,
2363 the footer) is true. When false, only the values used to expand
2364 the template are escaped.
2366 refchange_showgraph (bool)
2368 True iff refchanges emails should include a detailed graph.
2370 refchange_showlog (bool)
2372 True iff refchanges emails should include a detailed log.
2374 diffopts (list of strings)
2376 The options that should be passed to 'git diff' for the
2377 summary email. The value should be a list of strings
2378 representing words to be passed to the command.
2380 graphopts (list of strings)
2382 Analogous to diffopts, but contains options passed to
2383 'git log --graph' when generating the detailed graph for
2384 a set of commits (see refchange_showgraph)
2386 logopts (list of strings)
2388 Analogous to diffopts, but contains options passed to
2389 'git log' when generating the detailed log for a set of
2390 commits (see refchange_showlog)
2392 commitlogopts (list of strings)
2394 The options that should be passed to 'git log' for each
2395 commit mail. The value should be a list of strings
2396 representing words to be passed to the command.
2398 date_substitute (string)
2400 String to be used in substitution for 'Date:' at start of
2401 line in the output of 'git log'.
2404 On success do not write to stderr
2407 Write email to stdout rather than emailing. Useful for debugging
2409 combine_when_single_commit (bool)
2411 True if a combined email should be produced when a single
2412 new commit is pushed to a branch, False otherwise.
2414 from_refchange, from_commit (strings)
2416 Addresses to use for the From: field for refchange emails
2417 and commit emails respectively. Set from
2418 multimailhook.fromRefchange and multimailhook.fromCommit
2419 by ConfigEnvironmentMixin.
2421 log_file, error_log_file, debug_log_file (string)
2423 Name of a file to which logs should be sent.
2427 How verbose the system should be.
2428 - 0 (default): show info, errors, ...
2429 - 1 : show basic debug info
2432 REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$')
2434 def __init__(self, osenv=None):
2435 self.osenv = osenv or os.environ
2436 self.announce_show_shortlog = False
2437 self.commit_email_format = "text"
2438 self.html_in_intro = False
2439 self.html_in_footer = False
2440 self.commitBrowseURL = None
2441 self.maxcommitemails = 500
2442 self.excludemergerevisions = False
2443 self.diffopts = ['--stat', '--summary', '--find-copies-harder']
2444 self.graphopts = ['--oneline', '--decorate']
2446 self.refchange_showgraph = False
2447 self.refchange_showlog = False
2448 self.commitlogopts = ['-C', '--stat', '-p', '--cc']
2449 self.date_substitute = 'AuthorDate: '
2452 self.combine_when_single_commit = True
2455 self.COMPUTED_KEYS = [
2468 def get_logger(self):
2469 """Get (possibly creates) the logger associated to this environment."""
2470 if self.logger is None:
2471 self.logger = Logger(self)
2474 def get_repo_shortname(self):
2475 """Use the last part of the repo path, with ".git" stripped off if present."""
2477 basename = os.path.basename(os.path.abspath(self.get_repo_path()))
2478 m = self.REPO_NAME_RE.match(basename)
2480 return m.group('name')
2484 def get_pusher(self):
2485 raise NotImplementedError()
2487 def get_pusher_email(self):
2490 def get_fromaddr(self, change=None):
2491 config = Config('user')
2492 fromname = config.get('name', default='')
2493 fromemail = config.get('email', default='')
2495 return formataddr([fromname, fromemail])
2496 return self.get_sender()
2498 def get_administrator(self):
2499 return 'the administrator of this repository'
2501 def get_emailprefix(self):
2504 def get_repo_path(self):
2505 if read_git_output(['rev-parse', '--is-bare-repository']) == 'true':
2506 path = get_git_dir()
2508 path = read_git_output(['rev-parse', '--show-toplevel'])
2509 return os.path.abspath(path)
2511 def get_charset(self):
2514 def get_values(self):
2515 """Return a dictionary {keyword: expansion} for this Environment.
2517 This method is called by Change._compute_values(). The keys
2518 in the returned dictionary are available to be used in any of
2519 the templates. The dictionary is created by calling
2520 self.get_NAME() for each of the attributes named in
2521 COMPUTED_KEYS and recording those that do not return None.
2522 The return value is always a new dictionary."""
2524 if self._values is None:
2525 values = {'': ''} # %()s expands to the empty string.
2527 for key in self.COMPUTED_KEYS:
2528 value = getattr(self, 'get_%s' % (key,))()
2529 if value is not None:
2532 self._values = values
2534 return self._values.copy()
2536 def get_refchange_recipients(self, refchange):
2537 """Return the recipients for notifications about refchange.
2539 Return the list of email addresses to which notifications
2540 about the specified ReferenceChange should be sent."""
2542 raise NotImplementedError()
2544 def get_announce_recipients(self, annotated_tag_change):
2545 """Return the recipients for notifications about annotated_tag_change.
2547 Return the list of email addresses to which notifications
2548 about the specified AnnotatedTagChange should be sent."""
2550 raise NotImplementedError()
2552 def get_reply_to_refchange(self, refchange):
2553 return self.get_pusher_email()
2555 def get_revision_recipients(self, revision):
2556 """Return the recipients for messages about revision.
2558 Return the list of email addresses to which notifications
2559 about the specified Revision should be sent. This method
2560 could be overridden, for example, to take into account the
2561 contents of the revision when deciding whom to notify about
2562 it. For example, there could be a scheme for users to express
2563 interest in particular files or subdirectories, and only
2564 receive notification emails for revisions that affecting those
2567 raise NotImplementedError()
2569 def get_reply_to_commit(self, revision):
2570 return revision.author
2572 def get_default_ref_ignore_regex(self):
2573 # The commit messages of git notes are essentially meaningless
2574 # and "filenames" in git notes commits are an implementational
2575 # detail that might surprise users at first. As such, we
2576 # would need a completely different method for handling emails
2577 # of git notes in order for them to be of benefit for users,
2578 # which we simply do not have right now.
2579 return "^refs/notes/"
2581 def get_max_subject_length(self):
2582 """Return the maximal subject line (git log --oneline) length.
2583 Longer subject lines will be truncated."""
2584 raise NotImplementedError()
2586 def filter_body(self, lines):
2587 """Filter the lines intended for an email body.
2589 lines is an iterable over the lines that would go into the
2590 email body. Filter it (e.g., limit the number of lines, the
2591 line length, character set, etc.), returning another iterable.
2592 See FilterLinesEnvironmentMixin and MaxlinesEnvironmentMixin
2593 for classes implementing this functionality."""
2597 def log_msg(self, msg):
2598 """Write the string msg on a log file or on stderr.
2600 Sends the text to stderr by default, override to change the behavior."""
2601 self.get_logger().info(msg)
2603 def log_warning(self, msg):
2604 """Write the string msg on a log file or on stderr.
2606 Sends the text to stderr by default, override to change the behavior."""
2607 self.get_logger().warning(msg)
2609 def log_error(self, msg):
2610 """Write the string msg on a log file or on stderr.
2612 Sends the text to stderr by default, override to change the behavior."""
2613 self.get_logger().error(msg)
2619 class ConfigEnvironmentMixin(Environment):
2620 """A mixin that sets self.config to its constructor's config argument.
2622 This class's constructor consumes the "config" argument.
2624 Mixins that need to inspect the config should inherit from this
2625 class (1) to make sure that "config" is still in the constructor
2626 arguments with its own constructor runs and/or (2) to be sure that
2627 self.config is set after construction."""
2629 def __init__(self, config, **kw):
2630 super(ConfigEnvironmentMixin, self).__init__(**kw)
2631 self.config = config
2634 class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):
2635 """An Environment that reads most of its information from "git config"."""
2638 def forbid_field_values(name, value, forbidden):
2639 for forbidden_val in forbidden:
2640 if value is not None and value.lower() == forbidden:
2641 raise ConfigurationException(
2642 '"%s" is not an allowed setting for %s' % (value, name)
2645 def __init__(self, config, **kw):
2646 super(ConfigOptionsEnvironmentMixin, self).__init__(
2651 ('announce_show_shortlog', 'announceshortlog'),
2652 ('refchange_showgraph', 'refchangeShowGraph'),
2653 ('refchange_showlog', 'refchangeshowlog'),
2655 ('stdout', 'stdout'),
2657 val = config.get_bool(cfg)
2659 setattr(self, var, val)
2661 commit_email_format = config.get('commitEmailFormat')
2662 if commit_email_format is not None:
2663 if commit_email_format != "html" and commit_email_format != "text":
2665 '*** Unknown value for multimailhook.commitEmailFormat: %s\n' %
2666 commit_email_format +
2667 '*** Expected either "text" or "html". Ignoring.\n'
2670 self.commit_email_format = commit_email_format
2672 html_in_intro = config.get_bool('htmlInIntro')
2673 if html_in_intro is not None:
2674 self.html_in_intro = html_in_intro
2676 html_in_footer = config.get_bool('htmlInFooter')
2677 if html_in_footer is not None:
2678 self.html_in_footer = html_in_footer
2680 self.commitBrowseURL = config.get('commitBrowseURL')
2682 self.excludemergerevisions = config.get('excludeMergeRevisions')
2684 maxcommitemails = config.get('maxcommitemails')
2685 if maxcommitemails is not None:
2687 self.maxcommitemails = int(maxcommitemails)
2690 '*** Malformed value for multimailhook.maxCommitEmails: %s\n'
2692 '*** Expected a number. Ignoring.\n'
2695 diffopts = config.get('diffopts')
2696 if diffopts is not None:
2697 self.diffopts = shlex.split(diffopts)
2699 graphopts = config.get('graphOpts')
2700 if graphopts is not None:
2701 self.graphopts = shlex.split(graphopts)
2703 logopts = config.get('logopts')
2704 if logopts is not None:
2705 self.logopts = shlex.split(logopts)
2707 commitlogopts = config.get('commitlogopts')
2708 if commitlogopts is not None:
2709 self.commitlogopts = shlex.split(commitlogopts)
2711 date_substitute = config.get('dateSubstitute')
2712 if date_substitute == 'none':
2713 self.date_substitute = None
2714 elif date_substitute is not None:
2715 self.date_substitute = date_substitute
2717 reply_to = config.get('replyTo')
2718 self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to)
2719 self.forbid_field_values('replyToRefchange',
2720 self.__reply_to_refchange,
2722 self.__reply_to_commit = config.get('replyToCommit', default=reply_to)
2724 self.from_refchange = config.get('fromRefchange')
2725 self.forbid_field_values('fromRefchange',
2726 self.from_refchange,
2728 self.from_commit = config.get('fromCommit')
2729 self.forbid_field_values('fromCommit',
2733 combine = config.get_bool('combineWhenSingleCommit')
2734 if combine is not None:
2735 self.combine_when_single_commit = combine
2737 self.log_file = config.get('logFile', default=None)
2738 self.error_log_file = config.get('errorLogFile', default=None)
2739 self.debug_log_file = config.get('debugLogFile', default=None)
2740 if config.get_bool('Verbose', default=False):
2745 def get_administrator(self):
2747 self.config.get('administrator') or
2748 self.get_sender() or
2749 super(ConfigOptionsEnvironmentMixin, self).get_administrator()
2752 def get_repo_shortname(self):
2754 self.config.get('reponame') or
2755 super(ConfigOptionsEnvironmentMixin, self).get_repo_shortname()
2758 def get_emailprefix(self):
2759 emailprefix = self.config.get('emailprefix')
2760 if emailprefix is not None:
2761 emailprefix = emailprefix.strip()
2765 emailprefix = '[%(repo_shortname)s] '
2766 short_name = self.get_repo_shortname()
2768 return emailprefix % {'repo_shortname': short_name}
2770 self.get_logger().error(
2771 '*** Invalid multimailhook.emailPrefix: %s\n' % emailprefix +
2772 '*** %s\n' % sys.exc_info()[1] +
2773 "*** Only the '%(repo_shortname)s' placeholder is allowed\n"
2775 raise ConfigurationException(
2776 '"%s" is not an allowed setting for emailPrefix' % emailprefix
2779 def get_sender(self):
2780 return self.config.get('envelopesender')
2782 def process_addr(self, addr, change):
2783 if addr.lower() == 'author':
2784 if hasattr(change, 'author'):
2785 return change.author
2788 elif addr.lower() == 'pusher':
2789 return self.get_pusher_email()
2790 elif addr.lower() == 'none':
2795 def get_fromaddr(self, change=None):
2796 fromaddr = self.config.get('from')
2798 specific_fromaddr = change.get_specific_fromaddr()
2799 if specific_fromaddr:
2800 fromaddr = specific_fromaddr
2802 fromaddr = self.process_addr(fromaddr, change)
2805 return super(ConfigOptionsEnvironmentMixin, self).get_fromaddr(change)
2807 def get_reply_to_refchange(self, refchange):
2808 if self.__reply_to_refchange is None:
2809 return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_refchange(refchange)
2811 return self.process_addr(self.__reply_to_refchange, refchange)
2813 def get_reply_to_commit(self, revision):
2814 if self.__reply_to_commit is None:
2815 return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_commit(revision)
2817 return self.process_addr(self.__reply_to_commit, revision)
2819 def get_scancommitforcc(self):
2820 return self.config.get('scancommitforcc')
2823 class FilterLinesEnvironmentMixin(Environment):
2824 """Handle encoding and maximum line length of body lines.
2826 email_max_line_length (int or None)
2828 The maximum length of any single line in the email body.
2829 Longer lines are truncated at that length with ' [...]'
2834 If this field is set to True, then the email body text is
2835 expected to be UTF-8. Any invalid characters are
2836 converted to U+FFFD, the Unicode replacement character
2837 (encoded as UTF-8, of course).
2841 def __init__(self, strict_utf8=True,
2842 email_max_line_length=500, max_subject_length=500,
2844 super(FilterLinesEnvironmentMixin, self).__init__(**kw)
2845 self.__strict_utf8 = strict_utf8
2846 self.__email_max_line_length = email_max_line_length
2847 self.__max_subject_length = max_subject_length
2849 def filter_body(self, lines):
2850 lines = super(FilterLinesEnvironmentMixin, self).filter_body(lines)
2851 if self.__strict_utf8:
2853 lines = (line.decode(ENCODING, 'replace') for line in lines)
2854 # Limit the line length in Unicode-space to avoid
2855 # splitting characters:
2856 if self.__email_max_line_length > 0:
2857 lines = limit_linelength(lines, self.__email_max_line_length)
2859 lines = (line.encode(ENCODING, 'replace') for line in lines)
2860 elif self.__email_max_line_length:
2861 lines = limit_linelength(lines, self.__email_max_line_length)
2865 def get_max_subject_length(self):
2866 return self.__max_subject_length
2869 class ConfigFilterLinesEnvironmentMixin(
2870 ConfigEnvironmentMixin,
2871 FilterLinesEnvironmentMixin,
2873 """Handle encoding and maximum line length based on config."""
2875 def __init__(self, config, **kw):
2876 strict_utf8 = config.get_bool('emailstrictutf8', default=None)
2877 if strict_utf8 is not None:
2878 kw['strict_utf8'] = strict_utf8
2880 email_max_line_length = config.get('emailmaxlinelength')
2881 if email_max_line_length is not None:
2882 kw['email_max_line_length'] = int(email_max_line_length)
2884 max_subject_length = config.get('subjectMaxLength', default=email_max_line_length)
2885 if max_subject_length is not None:
2886 kw['max_subject_length'] = int(max_subject_length)
2888 super(ConfigFilterLinesEnvironmentMixin, self).__init__(
2893 class MaxlinesEnvironmentMixin(Environment):
2894 """Limit the email body to a specified number of lines."""
2896 def __init__(self, emailmaxlines, **kw):
2897 super(MaxlinesEnvironmentMixin, self).__init__(**kw)
2898 self.__emailmaxlines = emailmaxlines
2900 def filter_body(self, lines):
2901 lines = super(MaxlinesEnvironmentMixin, self).filter_body(lines)
2902 if self.__emailmaxlines > 0:
2903 lines = limit_lines(lines, self.__emailmaxlines)
2907 class ConfigMaxlinesEnvironmentMixin(
2908 ConfigEnvironmentMixin,
2909 MaxlinesEnvironmentMixin,
2911 """Limit the email body to the number of lines specified in config."""
2913 def __init__(self, config, **kw):
2914 emailmaxlines = int(config.get('emailmaxlines', default='0'))
2915 super(ConfigMaxlinesEnvironmentMixin, self).__init__(
2917 emailmaxlines=emailmaxlines,
2922 class FQDNEnvironmentMixin(Environment):
2923 """A mixin that sets the host's FQDN to its constructor argument."""
2925 def __init__(self, fqdn, **kw):
2926 super(FQDNEnvironmentMixin, self).__init__(**kw)
2927 self.COMPUTED_KEYS += ['fqdn']
2931 """Return the fully-qualified domain name for this host.
2933 Return None if it is unavailable or unwanted."""
2938 class ConfigFQDNEnvironmentMixin(
2939 ConfigEnvironmentMixin,
2940 FQDNEnvironmentMixin,
2942 """Read the FQDN from the config."""
2944 def __init__(self, config, **kw):
2945 fqdn = config.get('fqdn')
2946 super(ConfigFQDNEnvironmentMixin, self).__init__(
2953 class ComputeFQDNEnvironmentMixin(FQDNEnvironmentMixin):
2954 """Get the FQDN by calling socket.getfqdn()."""
2956 def __init__(self, **kw):
2957 super(ComputeFQDNEnvironmentMixin, self).__init__(
2958 fqdn=socket.getfqdn(),
2963 class PusherDomainEnvironmentMixin(ConfigEnvironmentMixin):
2964 """Deduce pusher_email from pusher by appending an emaildomain."""
2966 def __init__(self, **kw):
2967 super(PusherDomainEnvironmentMixin, self).__init__(**kw)
2968 self.__emaildomain = self.config.get('emaildomain')
2970 def get_pusher_email(self):
2971 if self.__emaildomain:
2972 # Derive the pusher's full email address in the default way:
2973 return '%s@%s' % (self.get_pusher(), self.__emaildomain)
2975 return super(PusherDomainEnvironmentMixin, self).get_pusher_email()
2978 class StaticRecipientsEnvironmentMixin(Environment):
2979 """Set recipients statically based on constructor parameters."""
2983 refchange_recipients, announce_recipients, revision_recipients, scancommitforcc,
2986 super(StaticRecipientsEnvironmentMixin, self).__init__(**kw)
2988 # The recipients for various types of notification emails, as
2989 # RFC 2822 email addresses separated by commas (or the empty
2990 # string if no recipients are configured). Although there is
2991 # a mechanism to choose the recipient lists based on on the
2992 # actual *contents* of the change being reported, we only
2993 # choose based on the *type* of the change. Therefore we can
2994 # compute them once and for all:
2995 self.__refchange_recipients = refchange_recipients
2996 self.__announce_recipients = announce_recipients
2997 self.__revision_recipients = revision_recipients
3000 if not (self.get_refchange_recipients(None) or
3001 self.get_announce_recipients(None) or
3002 self.get_revision_recipients(None) or
3003 self.get_scancommitforcc()):
3004 raise ConfigurationException('No email recipients configured!')
3005 super(StaticRecipientsEnvironmentMixin, self).check()
3007 def get_refchange_recipients(self, refchange):
3008 if self.__refchange_recipients is None:
3009 return super(StaticRecipientsEnvironmentMixin,
3010 self).get_refchange_recipients(refchange)
3011 return self.__refchange_recipients
3013 def get_announce_recipients(self, annotated_tag_change):
3014 if self.__announce_recipients is None:
3015 return super(StaticRecipientsEnvironmentMixin,
3016 self).get_refchange_recipients(annotated_tag_change)
3017 return self.__announce_recipients
3019 def get_revision_recipients(self, revision):
3020 if self.__revision_recipients is None:
3021 return super(StaticRecipientsEnvironmentMixin,
3022 self).get_refchange_recipients(revision)
3023 return self.__revision_recipients
3026 class CLIRecipientsEnvironmentMixin(Environment):
3027 """Mixin storing recipients information coming from the
3030 def __init__(self, cli_recipients=None, **kw):
3031 super(CLIRecipientsEnvironmentMixin, self).__init__(**kw)
3032 self.__cli_recipients = cli_recipients
3034 def get_refchange_recipients(self, refchange):
3035 if self.__cli_recipients is None:
3036 return super(CLIRecipientsEnvironmentMixin,
3037 self).get_refchange_recipients(refchange)
3038 return self.__cli_recipients
3040 def get_announce_recipients(self, annotated_tag_change):
3041 if self.__cli_recipients is None:
3042 return super(CLIRecipientsEnvironmentMixin,
3043 self).get_announce_recipients(annotated_tag_change)
3044 return self.__cli_recipients
3046 def get_revision_recipients(self, revision):
3047 if self.__cli_recipients is None:
3048 return super(CLIRecipientsEnvironmentMixin,
3049 self).get_revision_recipients(revision)
3050 return self.__cli_recipients
3053 class ConfigRecipientsEnvironmentMixin(
3054 ConfigEnvironmentMixin,
3055 StaticRecipientsEnvironmentMixin
3057 """Determine recipients statically based on config."""
3059 def __init__(self, config, **kw):
3060 super(ConfigRecipientsEnvironmentMixin, self).__init__(
3062 refchange_recipients=self._get_recipients(
3063 config, 'refchangelist', 'mailinglist',
3065 announce_recipients=self._get_recipients(
3066 config, 'announcelist', 'refchangelist', 'mailinglist',
3068 revision_recipients=self._get_recipients(
3069 config, 'commitlist', 'mailinglist',
3071 scancommitforcc=config.get('scancommitforcc'),
3075 def _get_recipients(self, config, *names):
3076 """Return the recipients for a particular type of message.
3078 Return the list of email addresses to which a particular type
3079 of notification email should be sent, by looking at the config
3080 value for "multimailhook.$name" for each of names. Use the
3081 value from the first name that is configured. The return
3082 value is a (possibly empty) string containing RFC 2822 email
3083 addresses separated by commas. If no configuration could be
3084 found, raise a ConfigurationException."""
3087 lines = config.get_all(name)
3088 if lines is not None:
3089 lines = [line.strip() for line in lines]
3090 # Single "none" is a special value equivalen to empty string.
3091 if lines == ['none']:
3093 return ', '.join(lines)
3098 class StaticRefFilterEnvironmentMixin(Environment):
3099 """Set branch filter statically based on constructor parameters."""
3101 def __init__(self, ref_filter_incl_regex, ref_filter_excl_regex,
3102 ref_filter_do_send_regex, ref_filter_dont_send_regex,
3104 super(StaticRefFilterEnvironmentMixin, self).__init__(**kw)
3106 if ref_filter_incl_regex and ref_filter_excl_regex:
3107 raise ConfigurationException(
3108 "Cannot specify both a ref inclusion and exclusion regex.")
3109 self.__is_inclusion_filter = bool(ref_filter_incl_regex)
3110 default_exclude = self.get_default_ref_ignore_regex()
3111 if ref_filter_incl_regex:
3112 ref_filter_regex = ref_filter_incl_regex
3113 elif ref_filter_excl_regex:
3114 ref_filter_regex = ref_filter_excl_regex + '|' + default_exclude
3116 ref_filter_regex = default_exclude
3118 self.__compiled_regex = re.compile(ref_filter_regex)
3120 raise ConfigurationException(
3121 'Invalid Ref Filter Regex "%s": %s' % (ref_filter_regex, sys.exc_info()[1]))
3123 if ref_filter_do_send_regex and ref_filter_dont_send_regex:
3124 raise ConfigurationException(
3125 "Cannot specify both a ref doSend and dontSend regex.")
3126 self.__is_do_send_filter = bool(ref_filter_do_send_regex)
3127 if ref_filter_do_send_regex:
3128 ref_filter_send_regex = ref_filter_do_send_regex
3129 elif ref_filter_dont_send_regex:
3130 ref_filter_send_regex = ref_filter_dont_send_regex
3132 ref_filter_send_regex = '.*'
3133 self.__is_do_send_filter = True
3135 self.__send_compiled_regex = re.compile(ref_filter_send_regex)
3137 raise ConfigurationException(
3138 'Invalid Ref Filter Regex "%s": %s' %
3139 (ref_filter_send_regex, sys.exc_info()[1]))
3141 def get_ref_filter_regex(self, send_filter=False):
3143 return self.__send_compiled_regex, self.__is_do_send_filter
3145 return self.__compiled_regex, self.__is_inclusion_filter
3148 class ConfigRefFilterEnvironmentMixin(
3149 ConfigEnvironmentMixin,
3150 StaticRefFilterEnvironmentMixin
3152 """Determine branch filtering statically based on config."""
3154 def _get_regex(self, config, key):
3155 """Get a list of whitespace-separated regex. The refFilter* config
3156 variables are multivalued (hence the use of get_all), and we
3157 allow each entry to be a whitespace-separated list (hence the
3158 split on each line). The whole thing is glued into a single regex."""
3159 values = config.get_all(key)
3164 for i in line.split():
3168 return '|'.join(items)
3170 def __init__(self, config, **kw):
3171 super(ConfigRefFilterEnvironmentMixin, self).__init__(
3173 ref_filter_incl_regex=self._get_regex(config, 'refFilterInclusionRegex'),
3174 ref_filter_excl_regex=self._get_regex(config, 'refFilterExclusionRegex'),
3175 ref_filter_do_send_regex=self._get_regex(config, 'refFilterDoSendRegex'),
3176 ref_filter_dont_send_regex=self._get_regex(config, 'refFilterDontSendRegex'),
3181 class ProjectdescEnvironmentMixin(Environment):
3182 """Make a "projectdesc" value available for templates.
3184 By default, it is set to the first line of $GIT_DIR/description
3185 (if that file is present and appears to be set meaningfully)."""
3187 def __init__(self, **kw):
3188 super(ProjectdescEnvironmentMixin, self).__init__(**kw)
3189 self.COMPUTED_KEYS += ['projectdesc']
3191 def get_projectdesc(self):
3192 """Return a one-line description of the project."""
3194 git_dir = get_git_dir()
3196 projectdesc = open(os.path.join(git_dir, 'description')).readline().strip()
3197 if projectdesc and not projectdesc.startswith('Unnamed repository'):
3202 return 'UNNAMED PROJECT'
3205 class GenericEnvironmentMixin(Environment):
3206 def get_pusher(self):
3207 return self.osenv.get('USER', self.osenv.get('USERNAME', 'unknown user'))
3210 class GitoliteEnvironmentHighPrecMixin(Environment):
3211 def get_pusher(self):
3212 return self.osenv.get('GL_USER', 'unknown user')
3215 class GitoliteEnvironmentLowPrecMixin(
3216 ConfigEnvironmentMixin,
3219 def get_repo_shortname(self):
3220 # The gitolite environment variable $GL_REPO is a pretty good
3221 # repo_shortname (though it's probably not as good as a value
3222 # the user might have explicitly put in his config).
3224 self.osenv.get('GL_REPO', None) or
3225 super(GitoliteEnvironmentLowPrecMixin, self).get_repo_shortname()
3229 def _compile_regex(re_template):
3231 re.compile(re_template % x)
3233 r'BEGIN\s+USER\s+EMAILS',
3235 r'END\s+USER\s+EMAILS',
3238 def get_fromaddr(self, change=None):
3239 GL_USER = self.osenv.get('GL_USER')
3240 if GL_USER is not None:
3241 # Find the path to gitolite.conf. Note that gitolite v3
3242 # did away with the GL_ADMINDIR and GL_CONF environment
3243 # variables (they are now hard-coded).
3244 GL_ADMINDIR = self.osenv.get(
3246 os.path.expanduser(os.path.join('~', '.gitolite')))
3247 GL_CONF = self.osenv.get(
3249 os.path.join(GL_ADMINDIR, 'conf', 'gitolite.conf'))
3251 mailaddress_map = self.config.get('MailaddressMap')
3252 # If relative, consider relative to GL_CONF:
3254 mailaddress_map = os.path.join(os.path.dirname(GL_CONF),
3256 if os.path.isfile(mailaddress_map):
3257 f = open(mailaddress_map, 'rU')
3259 # Leading '#' is optional
3260 re_begin, re_user, re_end = self._compile_regex(
3261 r'^(?:\s*#)?\s*%s\s*$')
3264 if re_begin.match(l) or re_end.match(l):
3265 continue # Ignore these lines
3266 m = re_user.match(l)
3268 if m.group(1) == GL_USER:
3271 continue # Not this user, but not an error
3272 raise ConfigurationException(
3273 "Syntax error in mail address map.\n"
3275 "Line: {}".format(mailaddress_map, l))
3280 if os.path.isfile(GL_CONF):
3281 f = open(GL_CONF, 'rU')
3283 in_user_emails_section = False
3284 re_begin, re_user, re_end = self._compile_regex(
3288 if not in_user_emails_section:
3289 if re_begin.match(l):
3290 in_user_emails_section = True
3294 m = re_user.match(l)
3295 if m and m.group(1) == GL_USER:
3299 return super(GitoliteEnvironmentLowPrecMixin, self).get_fromaddr(change)
3302 class IncrementalDateTime(object):
3303 """Simple wrapper to give incremental date/times.
3305 Each call will result in a date/time a second later than the
3306 previous call. This can be used to falsify email headers, to
3307 increase the likelihood that email clients sort the emails
3311 self.time = time.time()
3312 self.next = self.__next__ # Python 2 backward compatibility
3315 formatted = formatdate(self.time, True)
3320 class StashEnvironmentHighPrecMixin(Environment):
3321 def __init__(self, user=None, repo=None, **kw):
3322 super(StashEnvironmentHighPrecMixin,
3323 self).__init__(user=user, repo=repo, **kw)
3327 def get_pusher(self):
3328 return re.match(r'(.*?)\s*<', self.__user).group(1)
3330 def get_pusher_email(self):
3334 class StashEnvironmentLowPrecMixin(Environment):
3335 def __init__(self, user=None, repo=None, **kw):
3336 super(StashEnvironmentLowPrecMixin, self).__init__(**kw)
3340 def get_repo_shortname(self):
3343 def get_fromaddr(self, change=None):
3347 class GerritEnvironmentHighPrecMixin(Environment):
3348 def __init__(self, project=None, submitter=None, update_method=None, **kw):
3349 super(GerritEnvironmentHighPrecMixin,
3350 self).__init__(submitter=submitter, project=project, **kw)
3351 self.__project = project
3352 self.__submitter = submitter
3353 self.__update_method = update_method
3354 "Make an 'update_method' value available for templates."
3355 self.COMPUTED_KEYS += ['update_method']
3357 def get_pusher(self):
3358 if self.__submitter:
3359 if self.__submitter.find('<') != -1:
3360 # Submitter has a configured email, we transformed
3361 # __submitter into an RFC 2822 string already.
3362 return re.match(r'(.*?)\s*<', self.__submitter).group(1)
3364 # Submitter has no configured email, it's just his name.
3365 return self.__submitter
3367 # If we arrive here, this means someone pushed "Submit" from
3368 # the gerrit web UI for the CR (or used one of the programmatic
3369 # APIs to do the same, such as gerrit review) and the
3370 # merge/push was done by the Gerrit user. It was technically
3371 # triggered by someone else, but sadly we have no way of
3372 # determining who that someone else is at this point.
3373 return 'Gerrit' # 'unknown user'?
3375 def get_pusher_email(self):
3376 if self.__submitter:
3377 return self.__submitter
3379 return super(GerritEnvironmentHighPrecMixin, self).get_pusher_email()
3381 def get_default_ref_ignore_regex(self):
3382 default = super(GerritEnvironmentHighPrecMixin, self).get_default_ref_ignore_regex()
3383 return default + '|^refs/changes/|^refs/cache-automerge/|^refs/meta/'
3385 def get_revision_recipients(self, revision):
3386 # Merge commits created by Gerrit when users hit "Submit this patchset"
3387 # in the Web UI (or do equivalently with REST APIs or the gerrit review
3388 # command) are not something users want to see an individual email for.
3390 committer = read_git_output(['log', '--no-walk', '--format=%cN',
3392 if committer == 'Gerrit Code Review':
3395 return super(GerritEnvironmentHighPrecMixin, self).get_revision_recipients(revision)
3397 def get_update_method(self):
3398 return self.__update_method
3401 class GerritEnvironmentLowPrecMixin(Environment):
3402 def __init__(self, project=None, submitter=None, **kw):
3403 super(GerritEnvironmentLowPrecMixin, self).__init__(**kw)
3404 self.__project = project
3405 self.__submitter = submitter
3407 def get_repo_shortname(self):
3408 return self.__project
3410 def get_fromaddr(self, change=None):
3411 if self.__submitter and self.__submitter.find('<') != -1:
3412 return self.__submitter
3414 return super(GerritEnvironmentLowPrecMixin, self).get_fromaddr(change)
3418 """Represent an entire push (i.e., a group of ReferenceChanges).
3420 It is easy to figure out what commits were added to a *branch* by
3423 git rev-list change.old..change.new
3425 or removed from a *branch*:
3427 git rev-list change.new..change.old
3429 But it is not quite so trivial to determine which entirely new
3430 commits were added to the *repository* by a push and which old
3431 commits were discarded by a push. A big part of the job of this
3432 class is to figure out these things, and to make sure that new
3433 commits are only detailed once even if they were added to multiple
3436 The first step is to determine the "other" references--those
3437 unaffected by the current push. They are computed by listing all
3438 references then removing any affected by this push. The results
3439 are stored in Push._other_ref_sha1s.
3441 The commits contained in the repository before this push were
3443 git rev-list other1 other2 other3 ... change1.old change2.old ...
3445 Where "changeN.old" is the old value of one of the references
3446 affected by this push.
3448 The commits contained in the repository after this push are
3450 git rev-list other1 other2 other3 ... change1.new change2.new ...
3452 The commits added by this push are the difference between these
3453 two sets, which can be written
3456 ^other1 ^other2 ... \
3457 ^change1.old ^change2.old ... \
3458 change1.new change2.new ...
3460 The commits removed by this push can be computed by
3463 ^other1 ^other2 ... \
3464 ^change1.new ^change2.new ... \
3465 change1.old change2.old ...
3467 The last point is that it is possible that other pushes are
3468 occurring simultaneously to this one, so reference values can
3469 change at any time. It is impossible to eliminate all race
3470 conditions, but we reduce the window of time during which problems
3471 can occur by translating reference names to SHA1s as soon as
3472 possible and working with SHA1s thereafter (because SHA1s are
3475 # A map {(changeclass, changetype): integer} specifying the order
3476 # that reference changes will be processed if multiple reference
3477 # changes are included in a single push. The order is significant
3478 # mostly because new commit notifications are threaded together
3479 # with the first reference change that includes the commit. The
3480 # following order thus causes commits to be grouped with branch
3481 # changes (as opposed to tag changes) if possible.
3483 (value, i) for (i, value) in enumerate([
3484 (BranchChange, 'update'),
3485 (BranchChange, 'create'),
3486 (AnnotatedTagChange, 'update'),
3487 (AnnotatedTagChange, 'create'),
3488 (NonAnnotatedTagChange, 'update'),
3489 (NonAnnotatedTagChange, 'create'),
3490 (BranchChange, 'delete'),
3491 (AnnotatedTagChange, 'delete'),
3492 (NonAnnotatedTagChange, 'delete'),
3493 (OtherReferenceChange, 'update'),
3494 (OtherReferenceChange, 'create'),
3495 (OtherReferenceChange, 'delete'),
3499 def __init__(self, environment, changes, ignore_other_refs=False):
3500 self.changes = sorted(changes, key=self._sort_key)
3501 self.__other_ref_sha1s = None
3502 self.__cached_commits_spec = {}
3503 self.environment = environment
3505 if ignore_other_refs:
3506 self.__other_ref_sha1s = set()
3509 def _sort_key(klass, change):
3510 return (klass.SORT_ORDER[change.__class__, change.change_type], change.refname,)
3513 def _other_ref_sha1s(self):
3514 """The GitObjects referred to by references unaffected by this push.
3516 if self.__other_ref_sha1s is None:
3517 # The refnames being changed by this push:
3520 for change in self.changes
3523 # The SHA-1s of commits referred to by all references in this
3524 # repository *except* updated_refs:
3527 '%(objectname) %(objecttype) %(refname)\n'
3528 '%(*objectname) %(*objecttype) %(refname)'
3530 ref_filter_regex, is_inclusion_filter = \
3531 self.environment.get_ref_filter_regex()
3532 for line in read_git_lines(
3533 ['for-each-ref', '--format=%s' % (fmt,)]):
3534 (sha1, type, name) = line.split(' ', 2)
3535 if (sha1 and type == 'commit' and
3536 name not in updated_refs and
3537 include_ref(name, ref_filter_regex, is_inclusion_filter)):
3540 self.__other_ref_sha1s = sha1s
3542 return self.__other_ref_sha1s
3544 def _get_commits_spec_incl(self, new_or_old, reference_change=None):
3545 """Get new or old SHA-1 from one or each of the changed refs.
3547 Return a list of SHA-1 commit identifier strings suitable as
3548 arguments to 'git rev-list' (or 'git log' or ...). The
3549 returned identifiers are either the old or new values from one
3550 or all of the changed references, depending on the values of
3551 new_or_old and reference_change.
3553 new_or_old is either the string 'new' or the string 'old'. If
3554 'new', the returned SHA-1 identifiers are the new values from
3555 each changed reference. If 'old', the SHA-1 identifiers are
3556 the old values from each changed reference.
3558 If reference_change is specified and not None, only the new or
3559 old reference from the specified reference is included in the
3562 This function returns None if there are no matching revisions
3563 (e.g., because a branch was deleted and new_or_old is 'new').
3566 if not reference_change:
3568 getattr(change, new_or_old).sha1
3569 for change in self.changes
3570 if getattr(change, new_or_old)
3574 elif not getattr(reference_change, new_or_old).commit_sha1:
3577 incl_spec = [getattr(reference_change, new_or_old).commit_sha1]
3580 def _get_commits_spec_excl(self, new_or_old):
3581 """Get exclusion revisions for determining new or discarded commits.
3583 Return a list of strings suitable as arguments to 'git
3584 rev-list' (or 'git log' or ...) that will exclude all
3585 commits that, depending on the value of new_or_old, were
3586 either previously in the repository (useful for determining
3587 which commits are new to the repository) or currently in the
3588 repository (useful for determining which commits were
3589 discarded from the repository).
3591 new_or_old is either the string 'new' or the string 'old'. If
3592 'new', the commits to be excluded are those that were in the
3593 repository before the push. If 'old', the commits to be
3594 excluded are those that are currently in the repository. """
3596 old_or_new = {'old': 'new', 'new': 'old'}[new_or_old]
3597 excl_revs = self._other_ref_sha1s.union(
3598 getattr(change, old_or_new).sha1
3599 for change in self.changes
3600 if getattr(change, old_or_new).type in ['commit', 'tag']
3602 return ['^' + sha1 for sha1 in sorted(excl_revs)]
3604 def get_commits_spec(self, new_or_old, reference_change=None):
3605 """Get rev-list arguments for added or discarded commits.
3607 Return a list of strings suitable as arguments to 'git
3608 rev-list' (or 'git log' or ...) that select those commits
3609 that, depending on the value of new_or_old, are either new to
3610 the repository or were discarded from the repository.
3612 new_or_old is either the string 'new' or the string 'old'. If
3613 'new', the returned list is used to select commits that are
3614 new to the repository. If 'old', the returned value is used
3615 to select the commits that have been discarded from the
3618 If reference_change is specified and not None, the new or
3619 discarded commits are limited to those that are reachable from
3620 the new or old value of the specified reference.
3622 This function returns None if there are no added (or discarded)
3625 key = (new_or_old, reference_change)
3626 if key not in self.__cached_commits_spec:
3627 ret = self._get_commits_spec_incl(new_or_old, reference_change)
3629 ret.extend(self._get_commits_spec_excl(new_or_old))
3630 self.__cached_commits_spec[key] = ret
3631 return self.__cached_commits_spec[key]
3633 def get_new_commits(self, reference_change=None):
3634 """Return a list of commits added by this push.
3636 Return a list of the object names of commits that were added
3637 by the part of this push represented by reference_change. If
3638 reference_change is None, then return a list of *all* commits
3639 added by this push."""
3641 spec = self.get_commits_spec('new', reference_change)
3642 return git_rev_list(spec)
3644 def get_discarded_commits(self, reference_change):
3645 """Return a list of commits discarded by this push.
3647 Return a list of the object names of commits that were
3648 entirely discarded from the repository by the part of this
3649 push represented by reference_change."""
3651 spec = self.get_commits_spec('old', reference_change)
3652 return git_rev_list(spec)
3654 def send_emails(self, mailer, body_filter=None):
3655 """Use send all of the notification emails needed for this push.
3657 Use send all of the notification emails (including reference
3658 change emails and commit emails) needed for this push. Send
3659 the emails using mailer. If body_filter is not None, then use
3660 it to filter the lines that are intended for the email
3663 # The sha1s of commits that were introduced by this push.
3664 # They will be removed from this set as they are processed, to
3665 # guarantee that one (and only one) email is generated for
3667 unhandled_sha1s = set(self.get_new_commits())
3668 send_date = IncrementalDateTime()
3669 for change in self.changes:
3671 for sha1 in reversed(list(self.get_new_commits(change))):
3672 if sha1 in unhandled_sha1s:
3674 unhandled_sha1s.remove(sha1)
3676 # Check if we've got anyone to send to
3677 if not change.recipients:
3678 change.environment.log_warning(
3679 '*** no recipients configured so no email will be sent\n'
3680 '*** for %r update %s->%s'
3681 % (change.refname, change.old.sha1, change.new.sha1,)
3684 if not change.environment.quiet:
3685 change.environment.log_msg(
3686 'Sending notification emails to: %s' % (change.recipients,))
3687 extra_values = {'send_date': next(send_date)}
3689 rev = change.send_single_combined_email(sha1s)
3692 change.generate_combined_email(self, rev, body_filter, extra_values),
3695 # This change is now fully handled; no need to handle
3696 # individual revisions any further.
3700 change.generate_email(self, body_filter, extra_values),
3704 max_emails = change.environment.maxcommitemails
3705 if max_emails and len(sha1s) > max_emails:
3706 change.environment.log_warning(
3707 '*** Too many new commits (%d), not sending commit emails.\n' % len(sha1s) +
3708 '*** Try setting multimailhook.maxCommitEmails to a greater value\n' +
3709 '*** Currently, multimailhook.maxCommitEmails=%d' % max_emails
3713 for (num, sha1) in enumerate(sha1s):
3714 rev = Revision(change, GitObject(sha1), num=num + 1, tot=len(sha1s))
3715 if len(rev.parents) > 1 and change.environment.excludemergerevisions:
3716 # skipping a merge commit
3718 if not rev.recipients and rev.cc_recipients:
3719 change.environment.log_msg('*** Replacing Cc: with To:')
3720 rev.recipients = rev.cc_recipients
3721 rev.cc_recipients = None
3723 extra_values = {'send_date': next(send_date)}
3725 rev.generate_email(self, body_filter, extra_values),
3729 # Consistency check:
3731 change.environment.log_error(
3732 'ERROR: No emails were sent for the following new commits:\n'
3734 % ('\n '.join(sorted(unhandled_sha1s)),)
3738 def include_ref(refname, ref_filter_regex, is_inclusion_filter):
3739 does_match = bool(ref_filter_regex.search(refname))
3740 if is_inclusion_filter:
3742 else: # exclusion filter -- we include the ref if the regex doesn't match
3743 return not does_match
3746 def run_as_post_receive_hook(environment, mailer):
3748 send_filter_regex, send_is_inclusion_filter = environment.get_ref_filter_regex(True)
3749 ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(False)
3752 line = read_line(sys.stdin)
3755 (oldrev, newrev, refname) = line.strip().split(' ', 2)
3756 environment.get_logger().debug(
3757 "run_as_post_receive_hook: oldrev=%s, newrev=%s, refname=%s" %
3758 (oldrev, newrev, refname))
3760 if not include_ref(refname, ref_filter_regex, is_inclusion_filter):
3762 if not include_ref(refname, send_filter_regex, send_is_inclusion_filter):
3765 ReferenceChange.create(environment, oldrev, newrev, refname)
3770 push = Push(environment, changes)
3772 push.send_emails(mailer, body_filter=environment.filter_body)
3777 def run_as_update_hook(environment, mailer, refname, oldrev, newrev, force_send=False):
3779 send_filter_regex, send_is_inclusion_filter = environment.get_ref_filter_regex(True)
3780 ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(False)
3781 if not include_ref(refname, ref_filter_regex, is_inclusion_filter):
3783 if not include_ref(refname, send_filter_regex, send_is_inclusion_filter):
3786 ReferenceChange.create(
3788 read_git_output(['rev-parse', '--verify', oldrev]),
3789 read_git_output(['rev-parse', '--verify', newrev]),
3796 push = Push(environment, changes, force_send)
3798 push.send_emails(mailer, body_filter=environment.filter_body)
3803 def check_ref_filter(environment):
3804 send_filter_regex, send_is_inclusion = environment.get_ref_filter_regex(True)
3805 ref_filter_regex, ref_is_inclusion = environment.get_ref_filter_regex(False)
3807 def inc_exc_lusion(b):
3813 if send_filter_regex:
3814 sys.stdout.write("DoSend/DontSend filter regex (" +
3815 (inc_exc_lusion(send_is_inclusion)) +
3816 '): ' + send_filter_regex.pattern +
3818 if send_filter_regex:
3819 sys.stdout.write("Include/Exclude filter regex (" +
3820 (inc_exc_lusion(ref_is_inclusion)) +
3821 '): ' + ref_filter_regex.pattern +
3823 sys.stdout.write(os.linesep)
3826 "Refs marked as EXCLUDE are excluded by either refFilterInclusionRegex\n"
3827 "or refFilterExclusionRegex. No emails will be sent for commits included\n"
3829 "Refs marked as DONT-SEND are excluded by either refFilterDoSendRegex or\n"
3830 "refFilterDontSendRegex, but not by either refFilterInclusionRegex or\n"
3831 "refFilterExclusionRegex. Emails will be sent for commits included in these\n"
3832 "refs only when the commit reaches a ref which isn't excluded.\n"
3833 "Refs marked as DO-SEND are not excluded by any filter. Emails will\n"
3834 "be sent normally for commits included in these refs.\n")
3836 sys.stdout.write(os.linesep)
3838 for refname in read_git_lines(['for-each-ref', '--format', '%(refname)']):
3839 sys.stdout.write(refname)
3840 if not include_ref(refname, ref_filter_regex, ref_is_inclusion):
3841 sys.stdout.write(' EXCLUDE')
3842 elif not include_ref(refname, send_filter_regex, send_is_inclusion):
3843 sys.stdout.write(' DONT-SEND')
3845 sys.stdout.write(' DO-SEND')
3847 sys.stdout.write(os.linesep)
3850 def show_env(environment, out):
3851 out.write('Environment values:\n')
3852 for (k, v) in sorted(environment.get_values().items()):
3853 if k: # Don't show the {'' : ''} pair.
3854 out.write(' %s : %r\n' % (k, v))
3856 # Flush to avoid interleaving with further log output
3860 def check_setup(environment):
3862 show_env(environment, sys.stdout)
3863 sys.stdout.write("Now, checking that git-multimail's standard input "
3864 "is properly set ..." + os.linesep)
3865 sys.stdout.write("Please type some text and then press Return" + os.linesep)
3866 stdin = sys.stdin.readline()
3867 sys.stdout.write("You have just entered:" + os.linesep)
3868 sys.stdout.write(stdin)
3869 sys.stdout.write("git-multimail seems properly set up." + os.linesep)
3872 def choose_mailer(config, environment):
3873 mailer = config.get('mailer', default='sendmail')
3875 if mailer == 'smtp':
3876 smtpserver = config.get('smtpserver', default='localhost')
3877 smtpservertimeout = float(config.get('smtpservertimeout', default=10.0))
3878 smtpserverdebuglevel = int(config.get('smtpserverdebuglevel', default=0))
3879 smtpencryption = config.get('smtpencryption', default='none')
3880 smtpuser = config.get('smtpuser', default='')
3881 smtppass = config.get('smtppass', default='')
3882 smtpcacerts = config.get('smtpcacerts', default='')
3883 mailer = SMTPMailer(
3885 envelopesender=(environment.get_sender() or environment.get_fromaddr()),
3886 smtpserver=smtpserver, smtpservertimeout=smtpservertimeout,
3887 smtpserverdebuglevel=smtpserverdebuglevel,
3888 smtpencryption=smtpencryption,
3891 smtpcacerts=smtpcacerts
3893 elif mailer == 'sendmail':
3894 command = config.get('sendmailcommand')
3896 command = shlex.split(command)
3897 mailer = SendMailer(environment,
3898 command=command, envelopesender=environment.get_sender())
3900 environment.log_error(
3901 'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n' % mailer +
3902 'please use one of "smtp" or "sendmail".'
3908 KNOWN_ENVIRONMENTS = {
3909 'generic': {'highprec': GenericEnvironmentMixin},
3910 'gitolite': {'highprec': GitoliteEnvironmentHighPrecMixin,
3911 'lowprec': GitoliteEnvironmentLowPrecMixin},
3912 'stash': {'highprec': StashEnvironmentHighPrecMixin,
3913 'lowprec': StashEnvironmentLowPrecMixin},
3914 'gerrit': {'highprec': GerritEnvironmentHighPrecMixin,
3915 'lowprec': GerritEnvironmentLowPrecMixin},
3919 def choose_environment(config, osenv=None, env=None, recipients=None,
3921 env_name = choose_environment_name(config, env, osenv)
3922 environment_klass = build_environment_klass(env_name)
3923 env = build_environment(environment_klass, env_name, config,
3924 osenv, recipients, hook_info)
3928 def choose_environment_name(config, env, osenv):
3933 env = config.get('environment')
3936 if 'GL_USER' in osenv and 'GL_REPO' in osenv:
3943 COMMON_ENVIRONMENT_MIXINS = [
3944 ConfigRecipientsEnvironmentMixin,
3945 CLIRecipientsEnvironmentMixin,
3946 ConfigRefFilterEnvironmentMixin,
3947 ProjectdescEnvironmentMixin,
3948 ConfigMaxlinesEnvironmentMixin,
3949 ComputeFQDNEnvironmentMixin,
3950 ConfigFilterLinesEnvironmentMixin,
3951 PusherDomainEnvironmentMixin,
3952 ConfigOptionsEnvironmentMixin,
3956 def build_environment_klass(env_name):
3957 if 'class' in KNOWN_ENVIRONMENTS[env_name]:
3958 return KNOWN_ENVIRONMENTS[env_name]['class']
3960 environment_mixins = []
3961 known_env = KNOWN_ENVIRONMENTS[env_name]
3962 if 'highprec' in known_env:
3963 high_prec_mixin = known_env['highprec']
3964 environment_mixins.append(high_prec_mixin)
3965 environment_mixins = environment_mixins + COMMON_ENVIRONMENT_MIXINS
3966 if 'lowprec' in known_env:
3967 low_prec_mixin = known_env['lowprec']
3968 environment_mixins.append(low_prec_mixin)
3969 environment_mixins.append(Environment)
3970 klass_name = env_name.capitalize() + 'Environment'
3971 environment_klass = type(
3973 tuple(environment_mixins),
3976 KNOWN_ENVIRONMENTS[env_name]['class'] = environment_klass
3977 return environment_klass
3980 GerritEnvironment = build_environment_klass('gerrit')
3981 StashEnvironment = build_environment_klass('stash')
3982 GitoliteEnvironment = build_environment_klass('gitolite')
3983 GenericEnvironment = build_environment_klass('generic')
3986 def build_environment(environment_klass, env, config,
3987 osenv, recipients, hook_info):
3994 environment_kw['user'] = hook_info['stash_user']
3995 environment_kw['repo'] = hook_info['stash_repo']
3996 elif env == 'gerrit':
3997 environment_kw['project'] = hook_info['project']
3998 environment_kw['submitter'] = hook_info['submitter']
3999 environment_kw['update_method'] = hook_info['update_method']
4001 environment_kw['cli_recipients'] = recipients
4003 return environment_klass(**environment_kw)
4007 oldcwd = os.getcwd()
4010 os.chdir(os.path.dirname(os.path.realpath(__file__)))
4011 git_version = read_git_output(['describe', '--tags', 'HEAD'])
4012 if git_version == __version__:
4015 return '%s (%s)' % (__version__, git_version)
4023 def compute_gerrit_options(options, args, required_gerrit_options,
4025 if None in required_gerrit_options:
4026 raise SystemExit("Error: Specify all of --oldrev, --newrev, --refname, "
4027 "and --project; or none of them.")
4029 if options.environment not in (None, 'gerrit'):
4030 raise SystemExit("Non-gerrit environments incompatible with --oldrev, "
4031 "--newrev, --refname, and --project")
4032 options.environment = 'gerrit'
4035 raise SystemExit("Error: Positional parameters not allowed with "
4036 "--oldrev, --newrev, and --refname.")
4038 # Gerrit oddly omits 'refs/heads/' in the refname when calling
4039 # ref-updated hook; put it back.
4040 git_dir = get_git_dir()
4041 if (not os.path.exists(os.path.join(git_dir, raw_refname)) and
4042 os.path.exists(os.path.join(git_dir, 'refs', 'heads',
4044 options.refname = 'refs/heads/' + options.refname
4046 # New revisions can appear in a gerrit repository either due to someone
4047 # pushing directly (in which case options.submitter will be set), or they
4048 # can press "Submit this patchset" in the web UI for some CR (in which
4049 # case options.submitter will not be set and gerrit will not have provided
4050 # us the information about who pressed the button).
4052 # Note for the nit-picky: I'm lumping in REST API calls and the ssh
4053 # gerrit review command in with "Submit this patchset" button, since they
4054 # have the same effect.
4055 if options.submitter:
4056 update_method = 'pushed'
4057 # The submitter argument is almost an RFC 2822 email address; change it
4058 # from 'User Name (email@domain)' to 'User Name <email@domain>' so it is
4059 options.submitter = options.submitter.replace('(', '<').replace(')', '>')
4061 update_method = 'submitted'
4062 # Gerrit knew who submitted this patchset, but threw that information
4063 # away when it invoked this hook. However, *IF* Gerrit created a
4064 # merge to bring the patchset in (project 'Submit Type' is either
4065 # "Always Merge", or is "Merge if Necessary" and happens to be
4066 # necessary for this particular CR), then it will have the committer
4067 # of that merge be 'Gerrit Code Review' and the author will be the
4068 # person who requested the submission of the CR. Since this is fairly
4069 # likely for most gerrit installations (of a reasonable size), it's
4070 # worth the extra effort to try to determine the actual submitter.
4071 rev_info = read_git_lines(['log', '--no-walk', '--merges',
4072 '--format=%cN%n%aN <%aE>', options.newrev])
4073 if rev_info and rev_info[0] == 'Gerrit Code Review':
4074 options.submitter = rev_info[1]
4076 # We pass back refname, oldrev, newrev as args because then the
4077 # gerrit ref-updated hook is much like the git update hook
4079 [options.refname, options.oldrev, options.newrev],
4080 {'project': options.project, 'submitter': options.submitter,
4081 'update_method': update_method})
4084 def check_hook_specific_args(options, args):
4085 raw_refname = options.refname
4086 # Convert each string option unicode for Python3.
4088 opts = ['environment', 'recipients', 'oldrev', 'newrev', 'refname',
4089 'project', 'submitter', 'stash_user', 'stash_repo']
4091 if not hasattr(options, opt):
4093 obj = getattr(options, opt)
4095 enc = obj.encode('utf-8', 'surrogateescape')
4096 dec = enc.decode('utf-8', 'replace')
4097 setattr(options, opt, dec)
4099 # First check for stash arguments
4100 if (options.stash_user is None) != (options.stash_repo is None):
4101 raise SystemExit("Error: Specify both of --stash-user and "
4102 "--stash-repo or neither.")
4103 if options.stash_user:
4104 options.environment = 'stash'
4105 return options, args, {'stash_user': options.stash_user,
4106 'stash_repo': options.stash_repo}
4108 # Finally, check for gerrit specific arguments
4109 required_gerrit_options = (options.oldrev, options.newrev, options.refname,
4111 if required_gerrit_options != (None,) * 4:
4112 return compute_gerrit_options(options, args, required_gerrit_options,
4115 # No special options in use, just return what we started with
4116 return options, args, {}
4119 class Logger(object):
4120 def parse_verbose(self, verbose):
4122 return logging.DEBUG
4126 def create_log_file(self, environment, name, path, verbosity):
4127 log_file = logging.getLogger(name)
4128 file_handler = logging.FileHandler(path)
4129 log_fmt = logging.Formatter("%(asctime)s [%(levelname)-5.5s] %(message)s")
4130 file_handler.setFormatter(log_fmt)
4131 log_file.addHandler(file_handler)
4132 log_file.setLevel(verbosity)
4135 def __init__(self, environment):
4136 self.environment = environment
4138 stderr_log = logging.getLogger('git_multimail.stderr')
4140 class EncodedStderr(object):
4142 write_str(sys.stderr, x)
4147 stderr_handler = logging.StreamHandler(EncodedStderr())
4148 stderr_log.addHandler(stderr_handler)
4149 stderr_log.setLevel(self.parse_verbose(environment.verbose))
4150 self.loggers.append(stderr_log)
4152 if environment.debug_log_file is not None:
4153 debug_log_file = self.create_log_file(
4154 environment, 'git_multimail.debug', environment.debug_log_file, logging.DEBUG)
4155 self.loggers.append(debug_log_file)
4157 if environment.log_file is not None:
4158 log_file = self.create_log_file(
4159 environment, 'git_multimail.file', environment.log_file, logging.INFO)
4160 self.loggers.append(log_file)
4162 if environment.error_log_file is not None:
4163 error_log_file = self.create_log_file(
4164 environment, 'git_multimail.error', environment.error_log_file, logging.ERROR)
4165 self.loggers.append(error_log_file)
4167 def info(self, msg, *args, **kwargs):
4168 for l in self.loggers:
4169 l.info(msg, *args, **kwargs)
4171 def debug(self, msg, *args, **kwargs):
4172 for l in self.loggers:
4173 l.debug(msg, *args, **kwargs)
4175 def warning(self, msg, *args, **kwargs):
4176 for l in self.loggers:
4177 l.warning(msg, *args, **kwargs)
4179 def error(self, msg, *args, **kwargs):
4180 for l in self.loggers:
4181 l.error(msg, *args, **kwargs)
4185 parser = optparse.OptionParser(
4186 description=__doc__,
4187 usage='%prog [OPTIONS]\n or: %prog [OPTIONS] REFNAME OLDREV NEWREV',
4191 '--environment', '--env', action='store', type='choice',
4192 choices=list(KNOWN_ENVIRONMENTS.keys()), default=None,
4194 'Choose type of environment is in use. Default is taken from '
4195 'multimailhook.environment if set; otherwise "generic".'
4199 '--stdout', action='store_true', default=False,
4200 help='Output emails to stdout rather than sending them.',
4203 '--recipients', action='store', default=None,
4204 help='Set list of email recipients for all types of emails.',
4207 '--show-env', action='store_true', default=False,
4209 'Write to stderr the values determined for the environment '
4210 '(intended for debugging purposes), then proceed normally.'
4214 '--force-send', action='store_true', default=False,
4216 'Force sending refchange email when using as an update hook. '
4217 'This is useful to work around the unreliable new commits '
4218 'detection in this mode.'
4222 '-c', metavar="<name>=<value>", action='append',
4224 'Pass a configuration parameter through to git. The value given '
4225 'will override values from configuration files. See the -c option '
4226 'of git(1) for more details. (Only works with git >= 1.7.3)'
4230 '--version', '-v', action='store_true', default=False,
4232 "Display git-multimail's version"
4237 '--python-version', action='store_true', default=False,
4239 "Display the version of Python used by git-multimail"
4244 '--check-ref-filter', action='store_true', default=False,
4246 'List refs and show information on how git-multimail '
4247 'will process them.'
4251 # The following options permit this script to be run as a gerrit
4252 # ref-updated hook. See e.g.
4253 # code.google.com/p/gerrit/source/browse/Documentation/config-hooks.txt
4254 # We suppress help for these items, since these are specific to gerrit,
4255 # and we don't want users directly using them any way other than how the
4256 # gerrit ref-updated hook is called.
4257 parser.add_option('--oldrev', action='store', help=optparse.SUPPRESS_HELP)
4258 parser.add_option('--newrev', action='store', help=optparse.SUPPRESS_HELP)
4259 parser.add_option('--refname', action='store', help=optparse.SUPPRESS_HELP)
4260 parser.add_option('--project', action='store', help=optparse.SUPPRESS_HELP)
4261 parser.add_option('--submitter', action='store', help=optparse.SUPPRESS_HELP)
4263 # The following allow this to be run as a stash asynchronous post-receive
4264 # hook (almost identical to a git post-receive hook but triggered also for
4265 # merges of pull requests from the UI). We suppress help for these items,
4266 # since these are specific to stash.
4267 parser.add_option('--stash-user', action='store', help=optparse.SUPPRESS_HELP)
4268 parser.add_option('--stash-repo', action='store', help=optparse.SUPPRESS_HELP)
4270 (options, args) = parser.parse_args(args)
4271 (options, args, hook_info) = check_hook_specific_args(options, args)
4274 sys.stdout.write('git-multimail version ' + get_version() + '\n')
4277 if options.python_version:
4278 sys.stdout.write('Python version ' + sys.version + '\n')
4282 Config.add_config_parameters(options.c)
4284 config = Config('multimailhook')
4288 environment = choose_environment(
4289 config, osenv=os.environ,
4290 env=options.environment,
4291 recipients=options.recipients,
4292 hook_info=hook_info,
4295 if options.show_env:
4296 show_env(environment, sys.stderr)
4298 if options.stdout or environment.stdout:
4299 mailer = OutputMailer(sys.stdout, environment)
4301 mailer = choose_mailer(config, environment)
4303 must_check_setup = os.environ.get('GIT_MULTIMAIL_CHECK_SETUP')
4304 if must_check_setup == '':
4305 must_check_setup = False
4306 if options.check_ref_filter:
4307 check_ref_filter(environment)
4308 elif must_check_setup:
4309 check_setup(environment)
4310 # Dual mode: if arguments were specified on the command line, run
4311 # like an update hook; otherwise, run as a post-receive hook.
4314 parser.error('Need zero or three non-option arguments')
4315 (refname, oldrev, newrev) = args
4316 environment.get_logger().debug(
4317 "run_as_update_hook: refname=%s, oldrev=%s, newrev=%s, force_send=%s" %
4318 (refname, oldrev, newrev, options.force_send))
4319 run_as_update_hook(environment, mailer, refname, oldrev, newrev, options.force_send)
4321 run_as_post_receive_hook(environment, mailer)
4322 except ConfigurationException:
4323 sys.exit(sys.exc_info()[1])
4327 t, e, tb = sys.exc_info()
4329 sys.stderr.write('\n') # Avoid mixing message with previous output
4331 'Exception \'' + t.__name__ +
4332 '\' raised. Please report this as a bug to\n'
4333 'https://github.com/git-multimail/git-multimail/issues\n'
4334 'with the information below:\n\n'
4335 'git-multimail version ' + get_version() + '\n'
4336 'Python version ' + sys.version + '\n' +
4337 traceback.format_exc())
4339 environment.get_logger().error(msg)
4341 sys.stderr.write(msg)
4345 if __name__ == '__main__':