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