git-multimail: update to version 1.0.0
[git/git-svn.git] / contrib / hooks / multimail / git_multimail.py
blob8b58ed644423932309c3193ac46a5213ea29ca73
1 #! /usr/bin/env python2
3 # Copyright (c) 2012-2014 Michael Haggerty and others
4 # Derived from contrib/hooks/post-receive-email, which is
5 # Copyright (c) 2007 Andy Parkins
6 # and also includes contributions by other authors.
8 # This file is part of git-multimail.
10 # git-multimail is free software: you can redistribute it and/or
11 # modify it under the terms of the GNU General Public License version
12 # 2 as published by the Free Software Foundation.
14 # This program is distributed in the hope that it will be useful, but
15 # WITHOUT ANY WARRANTY; without even the implied warranty of
16 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
17 # General Public License for more details.
19 # You should have received a copy of the GNU General Public License
20 # along with this program. If not, see
21 # <http://www.gnu.org/licenses/>.
23 """Generate notification emails for pushes to a git repository.
25 This hook sends emails describing changes introduced by pushes to a
26 git repository. For each reference that was changed, it emits one
27 ReferenceChange email summarizing how the reference was changed,
28 followed by one Revision email for each new commit that was introduced
29 by the reference change.
31 Each commit is announced in exactly one Revision email. If the same
32 commit is merged into another branch in the same or a later push, then
33 the ReferenceChange email will list the commit's SHA1 and its one-line
34 summary, but no new Revision email will be generated.
36 This script is designed to be used as a "post-receive" hook in a git
37 repository (see githooks(5)). It can also be used as an "update"
38 script, but this usage is not completely reliable and is deprecated.
40 To help with debugging, this script accepts a --stdout option, which
41 causes the emails to be written to standard output rather than sent
42 using sendmail.
44 See the accompanying README file for the complete documentation.
46 """
48 import sys
49 import os
50 import re
51 import bisect
52 import socket
53 import subprocess
54 import shlex
55 import optparse
56 import smtplib
57 import time
59 try:
60 from email.utils import make_msgid
61 from email.utils import getaddresses
62 from email.utils import formataddr
63 from email.utils import formatdate
64 from email.header import Header
65 except ImportError:
66 # Prior to Python 2.5, the email module used different names:
67 from email.Utils import make_msgid
68 from email.Utils import getaddresses
69 from email.Utils import formataddr
70 from email.Utils import formatdate
71 from email.Header import Header
74 DEBUG = False
76 ZEROS = '0' * 40
77 LOGBEGIN = '- Log -----------------------------------------------------------------\n'
78 LOGEND = '-----------------------------------------------------------------------\n'
80 ADDR_HEADERS = set(['from', 'to', 'cc', 'bcc', 'reply-to', 'sender'])
82 # It is assumed in many places that the encoding is uniformly UTF-8,
83 # so changing these constants is unsupported. But define them here
84 # anyway, to make it easier to find (at least most of) the places
85 # where the encoding is important.
86 (ENCODING, CHARSET) = ('UTF-8', 'utf-8')
89 REF_CREATED_SUBJECT_TEMPLATE = (
90 '%(emailprefix)s%(refname_type)s %(short_refname)s created'
91 ' (now %(newrev_short)s)'
93 REF_UPDATED_SUBJECT_TEMPLATE = (
94 '%(emailprefix)s%(refname_type)s %(short_refname)s updated'
95 ' (%(oldrev_short)s -> %(newrev_short)s)'
97 REF_DELETED_SUBJECT_TEMPLATE = (
98 '%(emailprefix)s%(refname_type)s %(short_refname)s deleted'
99 ' (was %(oldrev_short)s)'
102 REFCHANGE_HEADER_TEMPLATE = """\
103 Date: %(send_date)s
104 To: %(recipients)s
105 Subject: %(subject)s
106 MIME-Version: 1.0
107 Content-Type: text/plain; charset=%(charset)s
108 Content-Transfer-Encoding: 8bit
109 Message-ID: %(msgid)s
110 From: %(fromaddr)s
111 Reply-To: %(reply_to)s
112 X-Git-Host: %(fqdn)s
113 X-Git-Repo: %(repo_shortname)s
114 X-Git-Refname: %(refname)s
115 X-Git-Reftype: %(refname_type)s
116 X-Git-Oldrev: %(oldrev)s
117 X-Git-Newrev: %(newrev)s
118 Auto-Submitted: auto-generated
121 REFCHANGE_INTRO_TEMPLATE = """\
122 This is an automated email from the git hooks/post-receive script.
124 %(pusher)s pushed a change to %(refname_type)s %(short_refname)s
125 in repository %(repo_shortname)s.
130 FOOTER_TEMPLATE = """\
132 -- \n\
133 To stop receiving notification emails like this one, please contact
134 %(administrator)s.
138 REWIND_ONLY_TEMPLATE = """\
139 This update removed existing revisions from the reference, leaving the
140 reference pointing at a previous point in the repository history.
142 * -- * -- N %(refname)s (%(newrev_short)s)
144 O -- O -- O (%(oldrev_short)s)
146 Any revisions marked "omits" are not gone; other references still
147 refer to them. Any revisions marked "discards" are gone forever.
151 NON_FF_TEMPLATE = """\
152 This update added new revisions after undoing existing revisions.
153 That is to say, some revisions that were in the old version of the
154 %(refname_type)s are not in the new version. This situation occurs
155 when a user --force pushes a change and generates a repository
156 containing something like this:
158 * -- * -- B -- O -- O -- O (%(oldrev_short)s)
160 N -- N -- N %(refname)s (%(newrev_short)s)
162 You should already have received notification emails for all of the O
163 revisions, and so the following emails describe only the N revisions
164 from the common base, B.
166 Any revisions marked "omits" are not gone; other references still
167 refer to them. Any revisions marked "discards" are gone forever.
171 NO_NEW_REVISIONS_TEMPLATE = """\
172 No new revisions were added by this update.
176 DISCARDED_REVISIONS_TEMPLATE = """\
177 This change permanently discards the following revisions:
181 NO_DISCARDED_REVISIONS_TEMPLATE = """\
182 The revisions that were on this %(refname_type)s are still contained in
183 other references; therefore, this change does not discard any commits
184 from the repository.
188 NEW_REVISIONS_TEMPLATE = """\
189 The %(tot)s revisions listed above as "new" are entirely new to this
190 repository and will be described in separate emails. The revisions
191 listed as "adds" were already present in the repository and have only
192 been added to this reference.
197 TAG_CREATED_TEMPLATE = """\
198 at %(newrev_short)-9s (%(newrev_type)s)
202 TAG_UPDATED_TEMPLATE = """\
203 *** WARNING: tag %(short_refname)s was modified! ***
205 from %(oldrev_short)-9s (%(oldrev_type)s)
206 to %(newrev_short)-9s (%(newrev_type)s)
210 TAG_DELETED_TEMPLATE = """\
211 *** WARNING: tag %(short_refname)s was deleted! ***
216 # The template used in summary tables. It looks best if this uses the
217 # same alignment as TAG_CREATED_TEMPLATE and TAG_UPDATED_TEMPLATE.
218 BRIEF_SUMMARY_TEMPLATE = """\
219 %(action)10s %(rev_short)-9s %(text)s
223 NON_COMMIT_UPDATE_TEMPLATE = """\
224 This is an unusual reference change because the reference did not
225 refer to a commit either before or after the change. We do not know
226 how to provide full information about this reference change.
230 REVISION_HEADER_TEMPLATE = """\
231 Date: %(send_date)s
232 To: %(recipients)s
233 Subject: %(emailprefix)s%(num)02d/%(tot)02d: %(oneline)s
234 MIME-Version: 1.0
235 Content-Type: text/plain; charset=%(charset)s
236 Content-Transfer-Encoding: 8bit
237 From: %(fromaddr)s
238 Reply-To: %(reply_to)s
239 In-Reply-To: %(reply_to_msgid)s
240 References: %(reply_to_msgid)s
241 X-Git-Host: %(fqdn)s
242 X-Git-Repo: %(repo_shortname)s
243 X-Git-Refname: %(refname)s
244 X-Git-Reftype: %(refname_type)s
245 X-Git-Rev: %(rev)s
246 Auto-Submitted: auto-generated
249 REVISION_INTRO_TEMPLATE = """\
250 This is an automated email from the git hooks/post-receive script.
252 %(pusher)s pushed a commit to %(refname_type)s %(short_refname)s
253 in repository %(repo_shortname)s.
258 REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE
261 class CommandError(Exception):
262 def __init__(self, cmd, retcode):
263 self.cmd = cmd
264 self.retcode = retcode
265 Exception.__init__(
266 self,
267 'Command "%s" failed with retcode %s' % (' '.join(cmd), retcode,)
271 class ConfigurationException(Exception):
272 pass
275 # The "git" program (this could be changed to include a full path):
276 GIT_EXECUTABLE = 'git'
279 # How "git" should be invoked (including global arguments), as a list
280 # of words. This variable is usually initialized automatically by
281 # read_git_output() via choose_git_command(), but if a value is set
282 # here then it will be used unconditionally.
283 GIT_CMD = None
286 def choose_git_command():
287 """Decide how to invoke git, and record the choice in GIT_CMD."""
289 global GIT_CMD
291 if GIT_CMD is None:
292 try:
293 # Check to see whether the "-c" option is accepted (it was
294 # only added in Git 1.7.2). We don't actually use the
295 # output of "git --version", though if we needed more
296 # specific version information this would be the place to
297 # do it.
298 cmd = [GIT_EXECUTABLE, '-c', 'foo.bar=baz', '--version']
299 read_output(cmd)
300 GIT_CMD = [GIT_EXECUTABLE, '-c', 'i18n.logoutputencoding=%s' % (ENCODING,)]
301 except CommandError:
302 GIT_CMD = [GIT_EXECUTABLE]
305 def read_git_output(args, input=None, keepends=False, **kw):
306 """Read the output of a Git command."""
308 if GIT_CMD is None:
309 choose_git_command()
311 return read_output(GIT_CMD + args, input=input, keepends=keepends, **kw)
314 def read_output(cmd, input=None, keepends=False, **kw):
315 if input:
316 stdin = subprocess.PIPE
317 else:
318 stdin = None
319 p = subprocess.Popen(
320 cmd, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw
322 (out, err) = p.communicate(input)
323 retcode = p.wait()
324 if retcode:
325 raise CommandError(cmd, retcode)
326 if not keepends:
327 out = out.rstrip('\n\r')
328 return out
331 def read_git_lines(args, keepends=False, **kw):
332 """Return the lines output by Git command.
334 Return as single lines, with newlines stripped off."""
336 return read_git_output(args, keepends=True, **kw).splitlines(keepends)
339 def header_encode(text, header_name=None):
340 """Encode and line-wrap the value of an email header field."""
342 try:
343 if isinstance(text, str):
344 text = text.decode(ENCODING, 'replace')
345 return Header(text, header_name=header_name).encode()
346 except UnicodeEncodeError:
347 return Header(text, header_name=header_name, charset=CHARSET,
348 errors='replace').encode()
351 def addr_header_encode(text, header_name=None):
352 """Encode and line-wrap the value of an email header field containing
353 email addresses."""
355 return Header(
356 ', '.join(
357 formataddr((header_encode(name), emailaddr))
358 for name, emailaddr in getaddresses([text])
360 header_name=header_name
361 ).encode()
364 class Config(object):
365 def __init__(self, section, git_config=None):
366 """Represent a section of the git configuration.
368 If git_config is specified, it is passed to "git config" in
369 the GIT_CONFIG environment variable, meaning that "git config"
370 will read the specified path rather than the Git default
371 config paths."""
373 self.section = section
374 if git_config:
375 self.env = os.environ.copy()
376 self.env['GIT_CONFIG'] = git_config
377 else:
378 self.env = None
380 @staticmethod
381 def _split(s):
382 """Split NUL-terminated values."""
384 words = s.split('\0')
385 assert words[-1] == ''
386 return words[:-1]
388 def get(self, name, default=None):
389 try:
390 values = self._split(read_git_output(
391 ['config', '--get', '--null', '%s.%s' % (self.section, name)],
392 env=self.env, keepends=True,
394 assert len(values) == 1
395 return values[0]
396 except CommandError:
397 return default
399 def get_bool(self, name, default=None):
400 try:
401 value = read_git_output(
402 ['config', '--get', '--bool', '%s.%s' % (self.section, name)],
403 env=self.env,
405 except CommandError:
406 return default
407 return value == 'true'
409 def get_all(self, name, default=None):
410 """Read a (possibly multivalued) setting from the configuration.
412 Return the result as a list of values, or default if the name
413 is unset."""
415 try:
416 return self._split(read_git_output(
417 ['config', '--get-all', '--null', '%s.%s' % (self.section, name)],
418 env=self.env, keepends=True,
420 except CommandError, e:
421 if e.retcode == 1:
422 # "the section or key is invalid"; i.e., there is no
423 # value for the specified key.
424 return default
425 else:
426 raise
428 def get_recipients(self, name, default=None):
429 """Read a recipients list from the configuration.
431 Return the result as a comma-separated list of email
432 addresses, or default if the option is unset. If the setting
433 has multiple values, concatenate them with comma separators."""
435 lines = self.get_all(name, default=None)
436 if lines is None:
437 return default
438 return ', '.join(line.strip() for line in lines)
440 def set(self, name, value):
441 read_git_output(
442 ['config', '%s.%s' % (self.section, name), value],
443 env=self.env,
446 def add(self, name, value):
447 read_git_output(
448 ['config', '--add', '%s.%s' % (self.section, name), value],
449 env=self.env,
452 def has_key(self, name):
453 return self.get_all(name, default=None) is not None
455 def unset_all(self, name):
456 try:
457 read_git_output(
458 ['config', '--unset-all', '%s.%s' % (self.section, name)],
459 env=self.env,
461 except CommandError, e:
462 if e.retcode == 5:
463 # The name doesn't exist, which is what we wanted anyway...
464 pass
465 else:
466 raise
468 def set_recipients(self, name, value):
469 self.unset_all(name)
470 for pair in getaddresses([value]):
471 self.add(name, formataddr(pair))
474 def generate_summaries(*log_args):
475 """Generate a brief summary for each revision requested.
477 log_args are strings that will be passed directly to "git log" as
478 revision selectors. Iterate over (sha1_short, subject) for each
479 commit specified by log_args (subject is the first line of the
480 commit message as a string without EOLs)."""
482 cmd = [
483 'log', '--abbrev', '--format=%h %s',
484 ] + list(log_args) + ['--']
485 for line in read_git_lines(cmd):
486 yield tuple(line.split(' ', 1))
489 def limit_lines(lines, max_lines):
490 for (index, line) in enumerate(lines):
491 if index < max_lines:
492 yield line
494 if index >= max_lines:
495 yield '... %d lines suppressed ...\n' % (index + 1 - max_lines,)
498 def limit_linelength(lines, max_linelength):
499 for line in lines:
500 # Don't forget that lines always include a trailing newline.
501 if len(line) > max_linelength + 1:
502 line = line[:max_linelength - 7] + ' [...]\n'
503 yield line
506 class CommitSet(object):
507 """A (constant) set of object names.
509 The set should be initialized with full SHA1 object names. The
510 __contains__() method returns True iff its argument is an
511 abbreviation of any the names in the set."""
513 def __init__(self, names):
514 self._names = sorted(names)
516 def __len__(self):
517 return len(self._names)
519 def __contains__(self, sha1_abbrev):
520 """Return True iff this set contains sha1_abbrev (which might be abbreviated)."""
522 i = bisect.bisect_left(self._names, sha1_abbrev)
523 return i < len(self) and self._names[i].startswith(sha1_abbrev)
526 class GitObject(object):
527 def __init__(self, sha1, type=None):
528 if sha1 == ZEROS:
529 self.sha1 = self.type = self.commit_sha1 = None
530 else:
531 self.sha1 = sha1
532 self.type = type or read_git_output(['cat-file', '-t', self.sha1])
534 if self.type == 'commit':
535 self.commit_sha1 = self.sha1
536 elif self.type == 'tag':
537 try:
538 self.commit_sha1 = read_git_output(
539 ['rev-parse', '--verify', '%s^0' % (self.sha1,)]
541 except CommandError:
542 # Cannot deref tag to determine commit_sha1
543 self.commit_sha1 = None
544 else:
545 self.commit_sha1 = None
547 self.short = read_git_output(['rev-parse', '--short', sha1])
549 def get_summary(self):
550 """Return (sha1_short, subject) for this commit."""
552 if not self.sha1:
553 raise ValueError('Empty commit has no summary')
555 return iter(generate_summaries('--no-walk', self.sha1)).next()
557 def __eq__(self, other):
558 return isinstance(other, GitObject) and self.sha1 == other.sha1
560 def __hash__(self):
561 return hash(self.sha1)
563 def __nonzero__(self):
564 return bool(self.sha1)
566 def __str__(self):
567 return self.sha1 or ZEROS
570 class Change(object):
571 """A Change that has been made to the Git repository.
573 Abstract class from which both Revisions and ReferenceChanges are
574 derived. A Change knows how to generate a notification email
575 describing itself."""
577 def __init__(self, environment):
578 self.environment = environment
579 self._values = None
581 def _compute_values(self):
582 """Return a dictionary {keyword : expansion} for this Change.
584 Derived classes overload this method to add more entries to
585 the return value. This method is used internally by
586 get_values(). The return value should always be a new
587 dictionary."""
589 return self.environment.get_values()
591 def get_values(self, **extra_values):
592 """Return a dictionary {keyword : expansion} for this Change.
594 Return a dictionary mapping keywords to the values that they
595 should be expanded to for this Change (used when interpolating
596 template strings). If any keyword arguments are supplied, add
597 those to the return value as well. The return value is always
598 a new dictionary."""
600 if self._values is None:
601 self._values = self._compute_values()
603 values = self._values.copy()
604 if extra_values:
605 values.update(extra_values)
606 return values
608 def expand(self, template, **extra_values):
609 """Expand template.
611 Expand the template (which should be a string) using string
612 interpolation of the values for this Change. If any keyword
613 arguments are provided, also include those in the keywords
614 available for interpolation."""
616 return template % self.get_values(**extra_values)
618 def expand_lines(self, template, **extra_values):
619 """Break template into lines and expand each line."""
621 values = self.get_values(**extra_values)
622 for line in template.splitlines(True):
623 yield line % values
625 def expand_header_lines(self, template, **extra_values):
626 """Break template into lines and expand each line as an RFC 2822 header.
628 Encode values and split up lines that are too long. Silently
629 skip lines that contain references to unknown variables."""
631 values = self.get_values(**extra_values)
632 for line in template.splitlines():
633 (name, value) = line.split(':', 1)
635 try:
636 value = value % values
637 except KeyError, e:
638 if DEBUG:
639 sys.stderr.write(
640 'Warning: unknown variable %r in the following line; line skipped:\n'
641 ' %s\n'
642 % (e.args[0], line,)
644 else:
645 if name.lower() in ADDR_HEADERS:
646 value = addr_header_encode(value, name)
647 else:
648 value = header_encode(value, name)
649 for splitline in ('%s: %s\n' % (name, value)).splitlines(True):
650 yield splitline
652 def generate_email_header(self):
653 """Generate the RFC 2822 email headers for this Change, a line at a time.
655 The output should not include the trailing blank line."""
657 raise NotImplementedError()
659 def generate_email_intro(self):
660 """Generate the email intro for this Change, a line at a time.
662 The output will be used as the standard boilerplate at the top
663 of the email body."""
665 raise NotImplementedError()
667 def generate_email_body(self):
668 """Generate the main part of the email body, a line at a time.
670 The text in the body might be truncated after a specified
671 number of lines (see multimailhook.emailmaxlines)."""
673 raise NotImplementedError()
675 def generate_email_footer(self):
676 """Generate the footer of the email, a line at a time.
678 The footer is always included, irrespective of
679 multimailhook.emailmaxlines."""
681 raise NotImplementedError()
683 def generate_email(self, push, body_filter=None, extra_header_values={}):
684 """Generate an email describing this change.
686 Iterate over the lines (including the header lines) of an
687 email describing this change. If body_filter is not None,
688 then use it to filter the lines that are intended for the
689 email body.
691 The extra_header_values field is received as a dict and not as
692 **kwargs, to allow passing other keyword arguments in the
693 future (e.g. passing extra values to generate_email_intro()"""
695 for line in self.generate_email_header(**extra_header_values):
696 yield line
697 yield '\n'
698 for line in self.generate_email_intro():
699 yield line
701 body = self.generate_email_body(push)
702 if body_filter is not None:
703 body = body_filter(body)
704 for line in body:
705 yield line
707 for line in self.generate_email_footer():
708 yield line
711 class Revision(Change):
712 """A Change consisting of a single git commit."""
714 def __init__(self, reference_change, rev, num, tot):
715 Change.__init__(self, reference_change.environment)
716 self.reference_change = reference_change
717 self.rev = rev
718 self.change_type = self.reference_change.change_type
719 self.refname = self.reference_change.refname
720 self.num = num
721 self.tot = tot
722 self.author = read_git_output(['log', '--no-walk', '--format=%aN <%aE>', self.rev.sha1])
723 self.recipients = self.environment.get_revision_recipients(self)
725 def _compute_values(self):
726 values = Change._compute_values(self)
728 oneline = read_git_output(
729 ['log', '--format=%s', '--no-walk', self.rev.sha1]
732 values['rev'] = self.rev.sha1
733 values['rev_short'] = self.rev.short
734 values['change_type'] = self.change_type
735 values['refname'] = self.refname
736 values['short_refname'] = self.reference_change.short_refname
737 values['refname_type'] = self.reference_change.refname_type
738 values['reply_to_msgid'] = self.reference_change.msgid
739 values['num'] = self.num
740 values['tot'] = self.tot
741 values['recipients'] = self.recipients
742 values['oneline'] = oneline
743 values['author'] = self.author
745 reply_to = self.environment.get_reply_to_commit(self)
746 if reply_to:
747 values['reply_to'] = reply_to
749 return values
751 def generate_email_header(self, **extra_values):
752 for line in self.expand_header_lines(
753 REVISION_HEADER_TEMPLATE, **extra_values
755 yield line
757 def generate_email_intro(self):
758 for line in self.expand_lines(REVISION_INTRO_TEMPLATE):
759 yield line
761 def generate_email_body(self, push):
762 """Show this revision."""
764 return read_git_lines(
765 ['log'] + self.environment.commitlogopts + ['-1', self.rev.sha1],
766 keepends=True,
769 def generate_email_footer(self):
770 return self.expand_lines(REVISION_FOOTER_TEMPLATE)
773 class ReferenceChange(Change):
774 """A Change to a Git reference.
776 An abstract class representing a create, update, or delete of a
777 Git reference. Derived classes handle specific types of reference
778 (e.g., tags vs. branches). These classes generate the main
779 reference change email summarizing the reference change and
780 whether it caused any any commits to be added or removed.
782 ReferenceChange objects are usually created using the static
783 create() method, which has the logic to decide which derived class
784 to instantiate."""
786 REF_RE = re.compile(r'^refs\/(?P<area>[^\/]+)\/(?P<shortname>.*)$')
788 @staticmethod
789 def create(environment, oldrev, newrev, refname):
790 """Return a ReferenceChange object representing the change.
792 Return an object that represents the type of change that is being
793 made. oldrev and newrev should be SHA1s or ZEROS."""
795 old = GitObject(oldrev)
796 new = GitObject(newrev)
797 rev = new or old
799 # The revision type tells us what type the commit is, combined with
800 # the location of the ref we can decide between
801 # - working branch
802 # - tracking branch
803 # - unannotated tag
804 # - annotated tag
805 m = ReferenceChange.REF_RE.match(refname)
806 if m:
807 area = m.group('area')
808 short_refname = m.group('shortname')
809 else:
810 area = ''
811 short_refname = refname
813 if rev.type == 'tag':
814 # Annotated tag:
815 klass = AnnotatedTagChange
816 elif rev.type == 'commit':
817 if area == 'tags':
818 # Non-annotated tag:
819 klass = NonAnnotatedTagChange
820 elif area == 'heads':
821 # Branch:
822 klass = BranchChange
823 elif area == 'remotes':
824 # Tracking branch:
825 sys.stderr.write(
826 '*** Push-update of tracking branch %r\n'
827 '*** - incomplete email generated.\n'
828 % (refname,)
830 klass = OtherReferenceChange
831 else:
832 # Some other reference namespace:
833 sys.stderr.write(
834 '*** Push-update of strange reference %r\n'
835 '*** - incomplete email generated.\n'
836 % (refname,)
838 klass = OtherReferenceChange
839 else:
840 # Anything else (is there anything else?)
841 sys.stderr.write(
842 '*** Unknown type of update to %r (%s)\n'
843 '*** - incomplete email generated.\n'
844 % (refname, rev.type,)
846 klass = OtherReferenceChange
848 return klass(
849 environment,
850 refname=refname, short_refname=short_refname,
851 old=old, new=new, rev=rev,
854 def __init__(self, environment, refname, short_refname, old, new, rev):
855 Change.__init__(self, environment)
856 self.change_type = {
857 (False, True) : 'create',
858 (True, True) : 'update',
859 (True, False) : 'delete',
860 }[bool(old), bool(new)]
861 self.refname = refname
862 self.short_refname = short_refname
863 self.old = old
864 self.new = new
865 self.rev = rev
866 self.msgid = make_msgid()
867 self.diffopts = environment.diffopts
868 self.logopts = environment.logopts
869 self.commitlogopts = environment.commitlogopts
870 self.showlog = environment.refchange_showlog
872 def _compute_values(self):
873 values = Change._compute_values(self)
875 values['change_type'] = self.change_type
876 values['refname_type'] = self.refname_type
877 values['refname'] = self.refname
878 values['short_refname'] = self.short_refname
879 values['msgid'] = self.msgid
880 values['recipients'] = self.recipients
881 values['oldrev'] = str(self.old)
882 values['oldrev_short'] = self.old.short
883 values['newrev'] = str(self.new)
884 values['newrev_short'] = self.new.short
886 if self.old:
887 values['oldrev_type'] = self.old.type
888 if self.new:
889 values['newrev_type'] = self.new.type
891 reply_to = self.environment.get_reply_to_refchange(self)
892 if reply_to:
893 values['reply_to'] = reply_to
895 return values
897 def get_subject(self):
898 template = {
899 'create' : REF_CREATED_SUBJECT_TEMPLATE,
900 'update' : REF_UPDATED_SUBJECT_TEMPLATE,
901 'delete' : REF_DELETED_SUBJECT_TEMPLATE,
902 }[self.change_type]
903 return self.expand(template)
905 def generate_email_header(self, **extra_values):
906 if 'subject' not in extra_values:
907 extra_values['subject'] = self.get_subject()
909 for line in self.expand_header_lines(
910 REFCHANGE_HEADER_TEMPLATE, **extra_values
912 yield line
914 def generate_email_intro(self):
915 for line in self.expand_lines(REFCHANGE_INTRO_TEMPLATE):
916 yield line
918 def generate_email_body(self, push):
919 """Call the appropriate body-generation routine.
921 Call one of generate_create_summary() /
922 generate_update_summary() / generate_delete_summary()."""
924 change_summary = {
925 'create' : self.generate_create_summary,
926 'delete' : self.generate_delete_summary,
927 'update' : self.generate_update_summary,
928 }[self.change_type](push)
929 for line in change_summary:
930 yield line
932 for line in self.generate_revision_change_summary(push):
933 yield line
935 def generate_email_footer(self):
936 return self.expand_lines(FOOTER_TEMPLATE)
938 def generate_revision_change_log(self, new_commits_list):
939 if self.showlog:
940 yield '\n'
941 yield 'Detailed log of new commits:\n\n'
942 for line in read_git_lines(
943 ['log', '--no-walk']
944 + self.logopts
945 + new_commits_list
946 + ['--'],
947 keepends=True,
949 yield line
951 def generate_revision_change_summary(self, push):
952 """Generate a summary of the revisions added/removed by this change."""
954 if self.new.commit_sha1 and not self.old.commit_sha1:
955 # A new reference was created. List the new revisions
956 # brought by the new reference (i.e., those revisions that
957 # were not in the repository before this reference
958 # change).
959 sha1s = list(push.get_new_commits(self))
960 sha1s.reverse()
961 tot = len(sha1s)
962 new_revisions = [
963 Revision(self, GitObject(sha1), num=i+1, tot=tot)
964 for (i, sha1) in enumerate(sha1s)
967 if new_revisions:
968 yield self.expand('This %(refname_type)s includes the following new commits:\n')
969 yield '\n'
970 for r in new_revisions:
971 (sha1, subject) = r.rev.get_summary()
972 yield r.expand(
973 BRIEF_SUMMARY_TEMPLATE, action='new', text=subject,
975 yield '\n'
976 for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot):
977 yield line
978 for line in self.generate_revision_change_log([r.rev.sha1 for r in new_revisions]):
979 yield line
980 else:
981 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
982 yield line
984 elif self.new.commit_sha1 and self.old.commit_sha1:
985 # A reference was changed to point at a different commit.
986 # List the revisions that were removed and/or added *from
987 # that reference* by this reference change, along with a
988 # diff between the trees for its old and new values.
990 # List of the revisions that were added to the branch by
991 # this update. Note this list can include revisions that
992 # have already had notification emails; we want such
993 # revisions in the summary even though we will not send
994 # new notification emails for them.
995 adds = list(generate_summaries(
996 '--topo-order', '--reverse', '%s..%s'
997 % (self.old.commit_sha1, self.new.commit_sha1,)
1000 # List of the revisions that were removed from the branch
1001 # by this update. This will be empty except for
1002 # non-fast-forward updates.
1003 discards = list(generate_summaries(
1004 '%s..%s' % (self.new.commit_sha1, self.old.commit_sha1,)
1007 if adds:
1008 new_commits_list = push.get_new_commits(self)
1009 else:
1010 new_commits_list = []
1011 new_commits = CommitSet(new_commits_list)
1013 if discards:
1014 discarded_commits = CommitSet(push.get_discarded_commits(self))
1015 else:
1016 discarded_commits = CommitSet([])
1018 if discards and adds:
1019 for (sha1, subject) in discards:
1020 if sha1 in discarded_commits:
1021 action = 'discards'
1022 else:
1023 action = 'omits'
1024 yield self.expand(
1025 BRIEF_SUMMARY_TEMPLATE, action=action,
1026 rev_short=sha1, text=subject,
1028 for (sha1, subject) in adds:
1029 if sha1 in new_commits:
1030 action = 'new'
1031 else:
1032 action = 'adds'
1033 yield self.expand(
1034 BRIEF_SUMMARY_TEMPLATE, action=action,
1035 rev_short=sha1, text=subject,
1037 yield '\n'
1038 for line in self.expand_lines(NON_FF_TEMPLATE):
1039 yield line
1041 elif discards:
1042 for (sha1, subject) in discards:
1043 if sha1 in discarded_commits:
1044 action = 'discards'
1045 else:
1046 action = 'omits'
1047 yield self.expand(
1048 BRIEF_SUMMARY_TEMPLATE, action=action,
1049 rev_short=sha1, text=subject,
1051 yield '\n'
1052 for line in self.expand_lines(REWIND_ONLY_TEMPLATE):
1053 yield line
1055 elif adds:
1056 (sha1, subject) = self.old.get_summary()
1057 yield self.expand(
1058 BRIEF_SUMMARY_TEMPLATE, action='from',
1059 rev_short=sha1, text=subject,
1061 for (sha1, subject) in adds:
1062 if sha1 in new_commits:
1063 action = 'new'
1064 else:
1065 action = 'adds'
1066 yield self.expand(
1067 BRIEF_SUMMARY_TEMPLATE, action=action,
1068 rev_short=sha1, text=subject,
1071 yield '\n'
1073 if new_commits:
1074 for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=len(new_commits)):
1075 yield line
1076 for line in self.generate_revision_change_log(new_commits_list):
1077 yield line
1078 else:
1079 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
1080 yield line
1082 # The diffstat is shown from the old revision to the new
1083 # revision. This is to show the truth of what happened in
1084 # this change. There's no point showing the stat from the
1085 # base to the new revision because the base is effectively a
1086 # random revision at this point - the user will be interested
1087 # in what this revision changed - including the undoing of
1088 # previous revisions in the case of non-fast-forward updates.
1089 yield '\n'
1090 yield 'Summary of changes:\n'
1091 for line in read_git_lines(
1092 ['diff-tree']
1093 + self.diffopts
1094 + ['%s..%s' % (self.old.commit_sha1, self.new.commit_sha1,)],
1095 keepends=True,
1097 yield line
1099 elif self.old.commit_sha1 and not self.new.commit_sha1:
1100 # A reference was deleted. List the revisions that were
1101 # removed from the repository by this reference change.
1103 sha1s = list(push.get_discarded_commits(self))
1104 tot = len(sha1s)
1105 discarded_revisions = [
1106 Revision(self, GitObject(sha1), num=i+1, tot=tot)
1107 for (i, sha1) in enumerate(sha1s)
1110 if discarded_revisions:
1111 for line in self.expand_lines(DISCARDED_REVISIONS_TEMPLATE):
1112 yield line
1113 yield '\n'
1114 for r in discarded_revisions:
1115 (sha1, subject) = r.rev.get_summary()
1116 yield r.expand(
1117 BRIEF_SUMMARY_TEMPLATE, action='discards', text=subject,
1119 else:
1120 for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE):
1121 yield line
1123 elif not self.old.commit_sha1 and not self.new.commit_sha1:
1124 for line in self.expand_lines(NON_COMMIT_UPDATE_TEMPLATE):
1125 yield line
1127 def generate_create_summary(self, push):
1128 """Called for the creation of a reference."""
1130 # This is a new reference and so oldrev is not valid
1131 (sha1, subject) = self.new.get_summary()
1132 yield self.expand(
1133 BRIEF_SUMMARY_TEMPLATE, action='at',
1134 rev_short=sha1, text=subject,
1136 yield '\n'
1138 def generate_update_summary(self, push):
1139 """Called for the change of a pre-existing branch."""
1141 return iter([])
1143 def generate_delete_summary(self, push):
1144 """Called for the deletion of any type of reference."""
1146 (sha1, subject) = self.old.get_summary()
1147 yield self.expand(
1148 BRIEF_SUMMARY_TEMPLATE, action='was',
1149 rev_short=sha1, text=subject,
1151 yield '\n'
1154 class BranchChange(ReferenceChange):
1155 refname_type = 'branch'
1157 def __init__(self, environment, refname, short_refname, old, new, rev):
1158 ReferenceChange.__init__(
1159 self, environment,
1160 refname=refname, short_refname=short_refname,
1161 old=old, new=new, rev=rev,
1163 self.recipients = environment.get_refchange_recipients(self)
1166 class AnnotatedTagChange(ReferenceChange):
1167 refname_type = 'annotated tag'
1169 def __init__(self, environment, refname, short_refname, old, new, rev):
1170 ReferenceChange.__init__(
1171 self, environment,
1172 refname=refname, short_refname=short_refname,
1173 old=old, new=new, rev=rev,
1175 self.recipients = environment.get_announce_recipients(self)
1176 self.show_shortlog = environment.announce_show_shortlog
1178 ANNOTATED_TAG_FORMAT = (
1179 '%(*objectname)\n'
1180 '%(*objecttype)\n'
1181 '%(taggername)\n'
1182 '%(taggerdate)'
1185 def describe_tag(self, push):
1186 """Describe the new value of an annotated tag."""
1188 # Use git for-each-ref to pull out the individual fields from
1189 # the tag
1190 [tagobject, tagtype, tagger, tagged] = read_git_lines(
1191 ['for-each-ref', '--format=%s' % (self.ANNOTATED_TAG_FORMAT,), self.refname],
1194 yield self.expand(
1195 BRIEF_SUMMARY_TEMPLATE, action='tagging',
1196 rev_short=tagobject, text='(%s)' % (tagtype,),
1198 if tagtype == 'commit':
1199 # If the tagged object is a commit, then we assume this is a
1200 # release, and so we calculate which tag this tag is
1201 # replacing
1202 try:
1203 prevtag = read_git_output(['describe', '--abbrev=0', '%s^' % (self.new,)])
1204 except CommandError:
1205 prevtag = None
1206 if prevtag:
1207 yield ' replaces %s\n' % (prevtag,)
1208 else:
1209 prevtag = None
1210 yield ' length %s bytes\n' % (read_git_output(['cat-file', '-s', tagobject]),)
1212 yield ' tagged by %s\n' % (tagger,)
1213 yield ' on %s\n' % (tagged,)
1214 yield '\n'
1216 # Show the content of the tag message; this might contain a
1217 # change log or release notes so is worth displaying.
1218 yield LOGBEGIN
1219 contents = list(read_git_lines(['cat-file', 'tag', self.new.sha1], keepends=True))
1220 contents = contents[contents.index('\n') + 1:]
1221 if contents and contents[-1][-1:] != '\n':
1222 contents.append('\n')
1223 for line in contents:
1224 yield line
1226 if self.show_shortlog and tagtype == 'commit':
1227 # Only commit tags make sense to have rev-list operations
1228 # performed on them
1229 yield '\n'
1230 if prevtag:
1231 # Show changes since the previous release
1232 revlist = read_git_output(
1233 ['rev-list', '--pretty=short', '%s..%s' % (prevtag, self.new,)],
1234 keepends=True,
1236 else:
1237 # No previous tag, show all the changes since time
1238 # began
1239 revlist = read_git_output(
1240 ['rev-list', '--pretty=short', '%s' % (self.new,)],
1241 keepends=True,
1243 for line in read_git_lines(['shortlog'], input=revlist, keepends=True):
1244 yield line
1246 yield LOGEND
1247 yield '\n'
1249 def generate_create_summary(self, push):
1250 """Called for the creation of an annotated tag."""
1252 for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1253 yield line
1255 for line in self.describe_tag(push):
1256 yield line
1258 def generate_update_summary(self, push):
1259 """Called for the update of an annotated tag.
1261 This is probably a rare event and may not even be allowed."""
1263 for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1264 yield line
1266 for line in self.describe_tag(push):
1267 yield line
1269 def generate_delete_summary(self, push):
1270 """Called when a non-annotated reference is updated."""
1272 for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1273 yield line
1275 yield self.expand(' tag was %(oldrev_short)s\n')
1276 yield '\n'
1279 class NonAnnotatedTagChange(ReferenceChange):
1280 refname_type = 'tag'
1282 def __init__(self, environment, refname, short_refname, old, new, rev):
1283 ReferenceChange.__init__(
1284 self, environment,
1285 refname=refname, short_refname=short_refname,
1286 old=old, new=new, rev=rev,
1288 self.recipients = environment.get_refchange_recipients(self)
1290 def generate_create_summary(self, push):
1291 """Called for the creation of an annotated tag."""
1293 for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1294 yield line
1296 def generate_update_summary(self, push):
1297 """Called when a non-annotated reference is updated."""
1299 for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1300 yield line
1302 def generate_delete_summary(self, push):
1303 """Called when a non-annotated reference is updated."""
1305 for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1306 yield line
1308 for line in ReferenceChange.generate_delete_summary(self, push):
1309 yield line
1312 class OtherReferenceChange(ReferenceChange):
1313 refname_type = 'reference'
1315 def __init__(self, environment, refname, short_refname, old, new, rev):
1316 # We use the full refname as short_refname, because otherwise
1317 # the full name of the reference would not be obvious from the
1318 # text of the email.
1319 ReferenceChange.__init__(
1320 self, environment,
1321 refname=refname, short_refname=refname,
1322 old=old, new=new, rev=rev,
1324 self.recipients = environment.get_refchange_recipients(self)
1327 class Mailer(object):
1328 """An object that can send emails."""
1330 def send(self, lines, to_addrs):
1331 """Send an email consisting of lines.
1333 lines must be an iterable over the lines constituting the
1334 header and body of the email. to_addrs is a list of recipient
1335 addresses (can be needed even if lines already contains a
1336 "To:" field). It can be either a string (comma-separated list
1337 of email addresses) or a Python list of individual email
1338 addresses.
1342 raise NotImplementedError()
1345 class SendMailer(Mailer):
1346 """Send emails using 'sendmail -oi -t'."""
1348 SENDMAIL_CANDIDATES = [
1349 '/usr/sbin/sendmail',
1350 '/usr/lib/sendmail',
1353 @staticmethod
1354 def find_sendmail():
1355 for path in SendMailer.SENDMAIL_CANDIDATES:
1356 if os.access(path, os.X_OK):
1357 return path
1358 else:
1359 raise ConfigurationException(
1360 'No sendmail executable found. '
1361 'Try setting multimailhook.sendmailCommand.'
1364 def __init__(self, command=None, envelopesender=None):
1365 """Construct a SendMailer instance.
1367 command should be the command and arguments used to invoke
1368 sendmail, as a list of strings. If an envelopesender is
1369 provided, it will also be passed to the command, via '-f
1370 envelopesender'."""
1372 if command:
1373 self.command = command[:]
1374 else:
1375 self.command = [self.find_sendmail(), '-oi', '-t']
1377 if envelopesender:
1378 self.command.extend(['-f', envelopesender])
1380 def send(self, lines, to_addrs):
1381 try:
1382 p = subprocess.Popen(self.command, stdin=subprocess.PIPE)
1383 except OSError, e:
1384 sys.stderr.write(
1385 '*** Cannot execute command: %s\n' % ' '.join(self.command)
1386 + '*** %s\n' % str(e)
1387 + '*** Try setting multimailhook.mailer to "smtp"\n'
1388 '*** to send emails without using the sendmail command.\n'
1390 sys.exit(1)
1391 try:
1392 p.stdin.writelines(lines)
1393 except:
1394 sys.stderr.write(
1395 '*** Error while generating commit email\n'
1396 '*** - mail sending aborted.\n'
1398 p.terminate()
1399 raise
1400 else:
1401 p.stdin.close()
1402 retcode = p.wait()
1403 if retcode:
1404 raise CommandError(self.command, retcode)
1407 class SMTPMailer(Mailer):
1408 """Send emails using Python's smtplib."""
1410 def __init__(self, envelopesender, smtpserver):
1411 if not envelopesender:
1412 sys.stderr.write(
1413 'fatal: git_multimail: cannot use SMTPMailer without a sender address.\n'
1414 'please set either multimailhook.envelopeSender or user.email\n'
1416 sys.exit(1)
1417 self.envelopesender = envelopesender
1418 self.smtpserver = smtpserver
1419 try:
1420 self.smtp = smtplib.SMTP(self.smtpserver)
1421 except Exception, e:
1422 sys.stderr.write('*** Error establishing SMTP connection to %s***\n' % self.smtpserver)
1423 sys.stderr.write('*** %s\n' % str(e))
1424 sys.exit(1)
1426 def __del__(self):
1427 self.smtp.quit()
1429 def send(self, lines, to_addrs):
1430 try:
1431 msg = ''.join(lines)
1432 # turn comma-separated list into Python list if needed.
1433 if isinstance(to_addrs, basestring):
1434 to_addrs = [email for (name, email) in getaddresses([to_addrs])]
1435 self.smtp.sendmail(self.envelopesender, to_addrs, msg)
1436 except Exception, e:
1437 sys.stderr.write('*** Error sending email***\n')
1438 sys.stderr.write('*** %s\n' % str(e))
1439 self.smtp.quit()
1440 sys.exit(1)
1443 class OutputMailer(Mailer):
1444 """Write emails to an output stream, bracketed by lines of '=' characters.
1446 This is intended for debugging purposes."""
1448 SEPARATOR = '=' * 75 + '\n'
1450 def __init__(self, f):
1451 self.f = f
1453 def send(self, lines, to_addrs):
1454 self.f.write(self.SEPARATOR)
1455 self.f.writelines(lines)
1456 self.f.write(self.SEPARATOR)
1459 def get_git_dir():
1460 """Determine GIT_DIR.
1462 Determine GIT_DIR either from the GIT_DIR environment variable or
1463 from the working directory, using Git's usual rules."""
1465 try:
1466 return read_git_output(['rev-parse', '--git-dir'])
1467 except CommandError:
1468 sys.stderr.write('fatal: git_multimail: not in a git directory\n')
1469 sys.exit(1)
1472 class Environment(object):
1473 """Describes the environment in which the push is occurring.
1475 An Environment object encapsulates information about the local
1476 environment. For example, it knows how to determine:
1478 * the name of the repository to which the push occurred
1480 * what user did the push
1482 * what users want to be informed about various types of changes.
1484 An Environment object is expected to have the following methods:
1486 get_repo_shortname()
1488 Return a short name for the repository, for display
1489 purposes.
1491 get_repo_path()
1493 Return the absolute path to the Git repository.
1495 get_emailprefix()
1497 Return a string that will be prefixed to every email's
1498 subject.
1500 get_pusher()
1502 Return the username of the person who pushed the changes.
1503 This value is used in the email body to indicate who
1504 pushed the change.
1506 get_pusher_email() (may return None)
1508 Return the email address of the person who pushed the
1509 changes. The value should be a single RFC 2822 email
1510 address as a string; e.g., "Joe User <user@example.com>"
1511 if available, otherwise "user@example.com". If set, the
1512 value is used as the Reply-To address for refchange
1513 emails. If it is impossible to determine the pusher's
1514 email, this attribute should be set to None (in which case
1515 no Reply-To header will be output).
1517 get_sender()
1519 Return the address to be used as the 'From' email address
1520 in the email envelope.
1522 get_fromaddr()
1524 Return the 'From' email address used in the email 'From:'
1525 headers. (May be a full RFC 2822 email address like 'Joe
1526 User <user@example.com>'.)
1528 get_administrator()
1530 Return the name and/or email of the repository
1531 administrator. This value is used in the footer as the
1532 person to whom requests to be removed from the
1533 notification list should be sent. Ideally, it should
1534 include a valid email address.
1536 get_reply_to_refchange()
1537 get_reply_to_commit()
1539 Return the address to use in the email "Reply-To" header,
1540 as a string. These can be an RFC 2822 email address, or
1541 None to omit the "Reply-To" header.
1542 get_reply_to_refchange() is used for refchange emails;
1543 get_reply_to_commit() is used for individual commit
1544 emails.
1546 They should also define the following attributes:
1548 announce_show_shortlog (bool)
1550 True iff announce emails should include a shortlog.
1552 refchange_showlog (bool)
1554 True iff refchanges emails should include a detailed log.
1556 diffopts (list of strings)
1558 The options that should be passed to 'git diff' for the
1559 summary email. The value should be a list of strings
1560 representing words to be passed to the command.
1562 logopts (list of strings)
1564 Analogous to diffopts, but contains options passed to
1565 'git log' when generating the detailed log for a set of
1566 commits (see refchange_showlog)
1568 commitlogopts (list of strings)
1570 The options that should be passed to 'git log' for each
1571 commit mail. The value should be a list of strings
1572 representing words to be passed to the command.
1576 REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$')
1578 def __init__(self, osenv=None):
1579 self.osenv = osenv or os.environ
1580 self.announce_show_shortlog = False
1581 self.maxcommitemails = 500
1582 self.diffopts = ['--stat', '--summary', '--find-copies-harder']
1583 self.logopts = []
1584 self.refchange_showlog = False
1585 self.commitlogopts = ['-C', '--stat', '-p', '--cc']
1587 self.COMPUTED_KEYS = [
1588 'administrator',
1589 'charset',
1590 'emailprefix',
1591 'fromaddr',
1592 'pusher',
1593 'pusher_email',
1594 'repo_path',
1595 'repo_shortname',
1596 'sender',
1599 self._values = None
1601 def get_repo_shortname(self):
1602 """Use the last part of the repo path, with ".git" stripped off if present."""
1604 basename = os.path.basename(os.path.abspath(self.get_repo_path()))
1605 m = self.REPO_NAME_RE.match(basename)
1606 if m:
1607 return m.group('name')
1608 else:
1609 return basename
1611 def get_pusher(self):
1612 raise NotImplementedError()
1614 def get_pusher_email(self):
1615 return None
1617 def get_administrator(self):
1618 return 'the administrator of this repository'
1620 def get_emailprefix(self):
1621 return ''
1623 def get_repo_path(self):
1624 if read_git_output(['rev-parse', '--is-bare-repository']) == 'true':
1625 path = get_git_dir()
1626 else:
1627 path = read_git_output(['rev-parse', '--show-toplevel'])
1628 return os.path.abspath(path)
1630 def get_charset(self):
1631 return CHARSET
1633 def get_values(self):
1634 """Return a dictionary {keyword : expansion} for this Environment.
1636 This method is called by Change._compute_values(). The keys
1637 in the returned dictionary are available to be used in any of
1638 the templates. The dictionary is created by calling
1639 self.get_NAME() for each of the attributes named in
1640 COMPUTED_KEYS and recording those that do not return None.
1641 The return value is always a new dictionary."""
1643 if self._values is None:
1644 values = {}
1646 for key in self.COMPUTED_KEYS:
1647 value = getattr(self, 'get_%s' % (key,))()
1648 if value is not None:
1649 values[key] = value
1651 self._values = values
1653 return self._values.copy()
1655 def get_refchange_recipients(self, refchange):
1656 """Return the recipients for notifications about refchange.
1658 Return the list of email addresses to which notifications
1659 about the specified ReferenceChange should be sent."""
1661 raise NotImplementedError()
1663 def get_announce_recipients(self, annotated_tag_change):
1664 """Return the recipients for notifications about annotated_tag_change.
1666 Return the list of email addresses to which notifications
1667 about the specified AnnotatedTagChange should be sent."""
1669 raise NotImplementedError()
1671 def get_reply_to_refchange(self, refchange):
1672 return self.get_pusher_email()
1674 def get_revision_recipients(self, revision):
1675 """Return the recipients for messages about revision.
1677 Return the list of email addresses to which notifications
1678 about the specified Revision should be sent. This method
1679 could be overridden, for example, to take into account the
1680 contents of the revision when deciding whom to notify about
1681 it. For example, there could be a scheme for users to express
1682 interest in particular files or subdirectories, and only
1683 receive notification emails for revisions that affecting those
1684 files."""
1686 raise NotImplementedError()
1688 def get_reply_to_commit(self, revision):
1689 return revision.author
1691 def filter_body(self, lines):
1692 """Filter the lines intended for an email body.
1694 lines is an iterable over the lines that would go into the
1695 email body. Filter it (e.g., limit the number of lines, the
1696 line length, character set, etc.), returning another iterable.
1697 See FilterLinesEnvironmentMixin and MaxlinesEnvironmentMixin
1698 for classes implementing this functionality."""
1700 return lines
1703 class ConfigEnvironmentMixin(Environment):
1704 """A mixin that sets self.config to its constructor's config argument.
1706 This class's constructor consumes the "config" argument.
1708 Mixins that need to inspect the config should inherit from this
1709 class (1) to make sure that "config" is still in the constructor
1710 arguments with its own constructor runs and/or (2) to be sure that
1711 self.config is set after construction."""
1713 def __init__(self, config, **kw):
1714 super(ConfigEnvironmentMixin, self).__init__(**kw)
1715 self.config = config
1718 class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):
1719 """An Environment that reads most of its information from "git config"."""
1721 def __init__(self, config, **kw):
1722 super(ConfigOptionsEnvironmentMixin, self).__init__(
1723 config=config, **kw
1726 self.announce_show_shortlog = config.get_bool(
1727 'announceshortlog', default=self.announce_show_shortlog
1730 self.refchange_showlog = config.get_bool(
1731 'refchangeshowlog', default=self.refchange_showlog
1734 maxcommitemails = config.get('maxcommitemails')
1735 if maxcommitemails is not None:
1736 try:
1737 self.maxcommitemails = int(maxcommitemails)
1738 except ValueError:
1739 sys.stderr.write(
1740 '*** Malformed value for multimailhook.maxCommitEmails: %s\n' % maxcommitemails
1741 + '*** Expected a number. Ignoring.\n'
1744 diffopts = config.get('diffopts')
1745 if diffopts is not None:
1746 self.diffopts = shlex.split(diffopts)
1748 logopts = config.get('logopts')
1749 if logopts is not None:
1750 self.logopts = shlex.split(logopts)
1752 commitlogopts = config.get('commitlogopts')
1753 if commitlogopts is not None:
1754 self.commitlogopts = shlex.split(commitlogopts)
1756 reply_to = config.get('replyTo')
1757 self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to)
1758 if (
1759 self.__reply_to_refchange is not None
1760 and self.__reply_to_refchange.lower() == 'author'
1762 raise ConfigurationException(
1763 '"author" is not an allowed setting for replyToRefchange'
1765 self.__reply_to_commit = config.get('replyToCommit', default=reply_to)
1767 def get_administrator(self):
1768 return (
1769 self.config.get('administrator')
1770 or self.get_sender()
1771 or super(ConfigOptionsEnvironmentMixin, self).get_administrator()
1774 def get_repo_shortname(self):
1775 return (
1776 self.config.get('reponame')
1777 or super(ConfigOptionsEnvironmentMixin, self).get_repo_shortname()
1780 def get_emailprefix(self):
1781 emailprefix = self.config.get('emailprefix')
1782 if emailprefix and emailprefix.strip():
1783 return emailprefix.strip() + ' '
1784 else:
1785 return '[%s] ' % (self.get_repo_shortname(),)
1787 def get_sender(self):
1788 return self.config.get('envelopesender')
1790 def get_fromaddr(self):
1791 fromaddr = self.config.get('from')
1792 if fromaddr:
1793 return fromaddr
1794 else:
1795 config = Config('user')
1796 fromname = config.get('name', default='')
1797 fromemail = config.get('email', default='')
1798 if fromemail:
1799 return formataddr([fromname, fromemail])
1800 else:
1801 return self.get_sender()
1803 def get_reply_to_refchange(self, refchange):
1804 if self.__reply_to_refchange is None:
1805 return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_refchange(refchange)
1806 elif self.__reply_to_refchange.lower() == 'pusher':
1807 return self.get_pusher_email()
1808 elif self.__reply_to_refchange.lower() == 'none':
1809 return None
1810 else:
1811 return self.__reply_to_refchange
1813 def get_reply_to_commit(self, revision):
1814 if self.__reply_to_commit is None:
1815 return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_commit(revision)
1816 elif self.__reply_to_commit.lower() == 'author':
1817 return revision.get_author()
1818 elif self.__reply_to_commit.lower() == 'pusher':
1819 return self.get_pusher_email()
1820 elif self.__reply_to_commit.lower() == 'none':
1821 return None
1822 else:
1823 return self.__reply_to_commit
1826 class FilterLinesEnvironmentMixin(Environment):
1827 """Handle encoding and maximum line length of body lines.
1829 emailmaxlinelength (int or None)
1831 The maximum length of any single line in the email body.
1832 Longer lines are truncated at that length with ' [...]'
1833 appended.
1835 strict_utf8 (bool)
1837 If this field is set to True, then the email body text is
1838 expected to be UTF-8. Any invalid characters are
1839 converted to U+FFFD, the Unicode replacement character
1840 (encoded as UTF-8, of course).
1844 def __init__(self, strict_utf8=True, emailmaxlinelength=500, **kw):
1845 super(FilterLinesEnvironmentMixin, self).__init__(**kw)
1846 self.__strict_utf8 = strict_utf8
1847 self.__emailmaxlinelength = emailmaxlinelength
1849 def filter_body(self, lines):
1850 lines = super(FilterLinesEnvironmentMixin, self).filter_body(lines)
1851 if self.__strict_utf8:
1852 lines = (line.decode(ENCODING, 'replace') for line in lines)
1853 # Limit the line length in Unicode-space to avoid
1854 # splitting characters:
1855 if self.__emailmaxlinelength:
1856 lines = limit_linelength(lines, self.__emailmaxlinelength)
1857 lines = (line.encode(ENCODING, 'replace') for line in lines)
1858 elif self.__emailmaxlinelength:
1859 lines = limit_linelength(lines, self.__emailmaxlinelength)
1861 return lines
1864 class ConfigFilterLinesEnvironmentMixin(
1865 ConfigEnvironmentMixin,
1866 FilterLinesEnvironmentMixin,
1868 """Handle encoding and maximum line length based on config."""
1870 def __init__(self, config, **kw):
1871 strict_utf8 = config.get_bool('emailstrictutf8', default=None)
1872 if strict_utf8 is not None:
1873 kw['strict_utf8'] = strict_utf8
1875 emailmaxlinelength = config.get('emailmaxlinelength')
1876 if emailmaxlinelength is not None:
1877 kw['emailmaxlinelength'] = int(emailmaxlinelength)
1879 super(ConfigFilterLinesEnvironmentMixin, self).__init__(
1880 config=config, **kw
1884 class MaxlinesEnvironmentMixin(Environment):
1885 """Limit the email body to a specified number of lines."""
1887 def __init__(self, emailmaxlines, **kw):
1888 super(MaxlinesEnvironmentMixin, self).__init__(**kw)
1889 self.__emailmaxlines = emailmaxlines
1891 def filter_body(self, lines):
1892 lines = super(MaxlinesEnvironmentMixin, self).filter_body(lines)
1893 if self.__emailmaxlines:
1894 lines = limit_lines(lines, self.__emailmaxlines)
1895 return lines
1898 class ConfigMaxlinesEnvironmentMixin(
1899 ConfigEnvironmentMixin,
1900 MaxlinesEnvironmentMixin,
1902 """Limit the email body to the number of lines specified in config."""
1904 def __init__(self, config, **kw):
1905 emailmaxlines = int(config.get('emailmaxlines', default='0'))
1906 super(ConfigMaxlinesEnvironmentMixin, self).__init__(
1907 config=config,
1908 emailmaxlines=emailmaxlines,
1909 **kw
1913 class FQDNEnvironmentMixin(Environment):
1914 """A mixin that sets the host's FQDN to its constructor argument."""
1916 def __init__(self, fqdn, **kw):
1917 super(FQDNEnvironmentMixin, self).__init__(**kw)
1918 self.COMPUTED_KEYS += ['fqdn']
1919 self.__fqdn = fqdn
1921 def get_fqdn(self):
1922 """Return the fully-qualified domain name for this host.
1924 Return None if it is unavailable or unwanted."""
1926 return self.__fqdn
1929 class ConfigFQDNEnvironmentMixin(
1930 ConfigEnvironmentMixin,
1931 FQDNEnvironmentMixin,
1933 """Read the FQDN from the config."""
1935 def __init__(self, config, **kw):
1936 fqdn = config.get('fqdn')
1937 super(ConfigFQDNEnvironmentMixin, self).__init__(
1938 config=config,
1939 fqdn=fqdn,
1940 **kw
1944 class ComputeFQDNEnvironmentMixin(FQDNEnvironmentMixin):
1945 """Get the FQDN by calling socket.getfqdn()."""
1947 def __init__(self, **kw):
1948 super(ComputeFQDNEnvironmentMixin, self).__init__(
1949 fqdn=socket.getfqdn(),
1950 **kw
1954 class PusherDomainEnvironmentMixin(ConfigEnvironmentMixin):
1955 """Deduce pusher_email from pusher by appending an emaildomain."""
1957 def __init__(self, **kw):
1958 super(PusherDomainEnvironmentMixin, self).__init__(**kw)
1959 self.__emaildomain = self.config.get('emaildomain')
1961 def get_pusher_email(self):
1962 if self.__emaildomain:
1963 # Derive the pusher's full email address in the default way:
1964 return '%s@%s' % (self.get_pusher(), self.__emaildomain)
1965 else:
1966 return super(PusherDomainEnvironmentMixin, self).get_pusher_email()
1969 class StaticRecipientsEnvironmentMixin(Environment):
1970 """Set recipients statically based on constructor parameters."""
1972 def __init__(
1973 self,
1974 refchange_recipients, announce_recipients, revision_recipients,
1975 **kw
1977 super(StaticRecipientsEnvironmentMixin, self).__init__(**kw)
1979 # The recipients for various types of notification emails, as
1980 # RFC 2822 email addresses separated by commas (or the empty
1981 # string if no recipients are configured). Although there is
1982 # a mechanism to choose the recipient lists based on on the
1983 # actual *contents* of the change being reported, we only
1984 # choose based on the *type* of the change. Therefore we can
1985 # compute them once and for all:
1986 if not (refchange_recipients
1987 or announce_recipients
1988 or revision_recipients):
1989 raise ConfigurationException('No email recipients configured!')
1990 self.__refchange_recipients = refchange_recipients
1991 self.__announce_recipients = announce_recipients
1992 self.__revision_recipients = revision_recipients
1994 def get_refchange_recipients(self, refchange):
1995 return self.__refchange_recipients
1997 def get_announce_recipients(self, annotated_tag_change):
1998 return self.__announce_recipients
2000 def get_revision_recipients(self, revision):
2001 return self.__revision_recipients
2004 class ConfigRecipientsEnvironmentMixin(
2005 ConfigEnvironmentMixin,
2006 StaticRecipientsEnvironmentMixin
2008 """Determine recipients statically based on config."""
2010 def __init__(self, config, **kw):
2011 super(ConfigRecipientsEnvironmentMixin, self).__init__(
2012 config=config,
2013 refchange_recipients=self._get_recipients(
2014 config, 'refchangelist', 'mailinglist',
2016 announce_recipients=self._get_recipients(
2017 config, 'announcelist', 'refchangelist', 'mailinglist',
2019 revision_recipients=self._get_recipients(
2020 config, 'commitlist', 'mailinglist',
2022 **kw
2025 def _get_recipients(self, config, *names):
2026 """Return the recipients for a particular type of message.
2028 Return the list of email addresses to which a particular type
2029 of notification email should be sent, by looking at the config
2030 value for "multimailhook.$name" for each of names. Use the
2031 value from the first name that is configured. The return
2032 value is a (possibly empty) string containing RFC 2822 email
2033 addresses separated by commas. If no configuration could be
2034 found, raise a ConfigurationException."""
2036 for name in names:
2037 retval = config.get_recipients(name)
2038 if retval is not None:
2039 return retval
2040 else:
2041 return ''
2044 class ProjectdescEnvironmentMixin(Environment):
2045 """Make a "projectdesc" value available for templates.
2047 By default, it is set to the first line of $GIT_DIR/description
2048 (if that file is present and appears to be set meaningfully)."""
2050 def __init__(self, **kw):
2051 super(ProjectdescEnvironmentMixin, self).__init__(**kw)
2052 self.COMPUTED_KEYS += ['projectdesc']
2054 def get_projectdesc(self):
2055 """Return a one-line descripition of the project."""
2057 git_dir = get_git_dir()
2058 try:
2059 projectdesc = open(os.path.join(git_dir, 'description')).readline().strip()
2060 if projectdesc and not projectdesc.startswith('Unnamed repository'):
2061 return projectdesc
2062 except IOError:
2063 pass
2065 return 'UNNAMED PROJECT'
2068 class GenericEnvironmentMixin(Environment):
2069 def get_pusher(self):
2070 return self.osenv.get('USER', 'unknown user')
2073 class GenericEnvironment(
2074 ProjectdescEnvironmentMixin,
2075 ConfigMaxlinesEnvironmentMixin,
2076 ComputeFQDNEnvironmentMixin,
2077 ConfigFilterLinesEnvironmentMixin,
2078 ConfigRecipientsEnvironmentMixin,
2079 PusherDomainEnvironmentMixin,
2080 ConfigOptionsEnvironmentMixin,
2081 GenericEnvironmentMixin,
2082 Environment,
2084 pass
2087 class GitoliteEnvironmentMixin(Environment):
2088 def get_repo_shortname(self):
2089 # The gitolite environment variable $GL_REPO is a pretty good
2090 # repo_shortname (though it's probably not as good as a value
2091 # the user might have explicitly put in his config).
2092 return (
2093 self.osenv.get('GL_REPO', None)
2094 or super(GitoliteEnvironmentMixin, self).get_repo_shortname()
2097 def get_pusher(self):
2098 return self.osenv.get('GL_USER', 'unknown user')
2101 class IncrementalDateTime(object):
2102 """Simple wrapper to give incremental date/times.
2104 Each call will result in a date/time a second later than the
2105 previous call. This can be used to falsify email headers, to
2106 increase the likelihood that email clients sort the emails
2107 correctly."""
2109 def __init__(self):
2110 self.time = time.time()
2112 def next(self):
2113 formatted = formatdate(self.time, True)
2114 self.time += 1
2115 return formatted
2118 class GitoliteEnvironment(
2119 ProjectdescEnvironmentMixin,
2120 ConfigMaxlinesEnvironmentMixin,
2121 ComputeFQDNEnvironmentMixin,
2122 ConfigFilterLinesEnvironmentMixin,
2123 ConfigRecipientsEnvironmentMixin,
2124 PusherDomainEnvironmentMixin,
2125 ConfigOptionsEnvironmentMixin,
2126 GitoliteEnvironmentMixin,
2127 Environment,
2129 pass
2132 class Push(object):
2133 """Represent an entire push (i.e., a group of ReferenceChanges).
2135 It is easy to figure out what commits were added to a *branch* by
2136 a Reference change:
2138 git rev-list change.old..change.new
2140 or removed from a *branch*:
2142 git rev-list change.new..change.old
2144 But it is not quite so trivial to determine which entirely new
2145 commits were added to the *repository* by a push and which old
2146 commits were discarded by a push. A big part of the job of this
2147 class is to figure out these things, and to make sure that new
2148 commits are only detailed once even if they were added to multiple
2149 references.
2151 The first step is to determine the "other" references--those
2152 unaffected by the current push. They are computed by
2153 Push._compute_other_ref_sha1s() by listing all references then
2154 removing any affected by this push.
2156 The commits contained in the repository before this push were
2158 git rev-list other1 other2 other3 ... change1.old change2.old ...
2160 Where "changeN.old" is the old value of one of the references
2161 affected by this push.
2163 The commits contained in the repository after this push are
2165 git rev-list other1 other2 other3 ... change1.new change2.new ...
2167 The commits added by this push are the difference between these
2168 two sets, which can be written
2170 git rev-list \
2171 ^other1 ^other2 ... \
2172 ^change1.old ^change2.old ... \
2173 change1.new change2.new ...
2175 The commits removed by this push can be computed by
2177 git rev-list \
2178 ^other1 ^other2 ... \
2179 ^change1.new ^change2.new ... \
2180 change1.old change2.old ...
2182 The last point is that it is possible that other pushes are
2183 occurring simultaneously to this one, so reference values can
2184 change at any time. It is impossible to eliminate all race
2185 conditions, but we reduce the window of time during which problems
2186 can occur by translating reference names to SHA1s as soon as
2187 possible and working with SHA1s thereafter (because SHA1s are
2188 immutable)."""
2190 # A map {(changeclass, changetype) : integer} specifying the order
2191 # that reference changes will be processed if multiple reference
2192 # changes are included in a single push. The order is significant
2193 # mostly because new commit notifications are threaded together
2194 # with the first reference change that includes the commit. The
2195 # following order thus causes commits to be grouped with branch
2196 # changes (as opposed to tag changes) if possible.
2197 SORT_ORDER = dict(
2198 (value, i) for (i, value) in enumerate([
2199 (BranchChange, 'update'),
2200 (BranchChange, 'create'),
2201 (AnnotatedTagChange, 'update'),
2202 (AnnotatedTagChange, 'create'),
2203 (NonAnnotatedTagChange, 'update'),
2204 (NonAnnotatedTagChange, 'create'),
2205 (BranchChange, 'delete'),
2206 (AnnotatedTagChange, 'delete'),
2207 (NonAnnotatedTagChange, 'delete'),
2208 (OtherReferenceChange, 'update'),
2209 (OtherReferenceChange, 'create'),
2210 (OtherReferenceChange, 'delete'),
2214 def __init__(self, changes):
2215 self.changes = sorted(changes, key=self._sort_key)
2217 # The SHA-1s of commits referred to by references unaffected
2218 # by this push:
2219 other_ref_sha1s = self._compute_other_ref_sha1s()
2221 self._old_rev_exclusion_spec = self._compute_rev_exclusion_spec(
2222 other_ref_sha1s.union(
2223 change.old.sha1
2224 for change in self.changes
2225 if change.old.type in ['commit', 'tag']
2228 self._new_rev_exclusion_spec = self._compute_rev_exclusion_spec(
2229 other_ref_sha1s.union(
2230 change.new.sha1
2231 for change in self.changes
2232 if change.new.type in ['commit', 'tag']
2236 @classmethod
2237 def _sort_key(klass, change):
2238 return (klass.SORT_ORDER[change.__class__, change.change_type], change.refname,)
2240 def _compute_other_ref_sha1s(self):
2241 """Return the GitObjects referred to by references unaffected by this push."""
2243 # The refnames being changed by this push:
2244 updated_refs = set(
2245 change.refname
2246 for change in self.changes
2249 # The SHA-1s of commits referred to by all references in this
2250 # repository *except* updated_refs:
2251 sha1s = set()
2252 fmt = (
2253 '%(objectname) %(objecttype) %(refname)\n'
2254 '%(*objectname) %(*objecttype) %(refname)'
2256 for line in read_git_lines(['for-each-ref', '--format=%s' % (fmt,)]):
2257 (sha1, type, name) = line.split(' ', 2)
2258 if sha1 and type == 'commit' and name not in updated_refs:
2259 sha1s.add(sha1)
2261 return sha1s
2263 def _compute_rev_exclusion_spec(self, sha1s):
2264 """Return an exclusion specification for 'git rev-list'.
2266 git_objects is an iterable over GitObject instances. Return a
2267 string that can be passed to the standard input of 'git
2268 rev-list --stdin' to exclude all of the commits referred to by
2269 git_objects."""
2271 return ''.join(
2272 ['^%s\n' % (sha1,) for sha1 in sorted(sha1s)]
2275 def get_new_commits(self, reference_change=None):
2276 """Return a list of commits added by this push.
2278 Return a list of the object names of commits that were added
2279 by the part of this push represented by reference_change. If
2280 reference_change is None, then return a list of *all* commits
2281 added by this push."""
2283 if not reference_change:
2284 new_revs = sorted(
2285 change.new.sha1
2286 for change in self.changes
2287 if change.new
2289 elif not reference_change.new.commit_sha1:
2290 return []
2291 else:
2292 new_revs = [reference_change.new.commit_sha1]
2294 cmd = ['rev-list', '--stdin'] + new_revs
2295 return read_git_lines(cmd, input=self._old_rev_exclusion_spec)
2297 def get_discarded_commits(self, reference_change):
2298 """Return a list of commits discarded by this push.
2300 Return a list of the object names of commits that were
2301 entirely discarded from the repository by the part of this
2302 push represented by reference_change."""
2304 if not reference_change.old.commit_sha1:
2305 return []
2306 else:
2307 old_revs = [reference_change.old.commit_sha1]
2309 cmd = ['rev-list', '--stdin'] + old_revs
2310 return read_git_lines(cmd, input=self._new_rev_exclusion_spec)
2312 def send_emails(self, mailer, body_filter=None):
2313 """Use send all of the notification emails needed for this push.
2315 Use send all of the notification emails (including reference
2316 change emails and commit emails) needed for this push. Send
2317 the emails using mailer. If body_filter is not None, then use
2318 it to filter the lines that are intended for the email
2319 body."""
2321 # The sha1s of commits that were introduced by this push.
2322 # They will be removed from this set as they are processed, to
2323 # guarantee that one (and only one) email is generated for
2324 # each new commit.
2325 unhandled_sha1s = set(self.get_new_commits())
2326 send_date = IncrementalDateTime()
2327 for change in self.changes:
2328 # Check if we've got anyone to send to
2329 if not change.recipients:
2330 sys.stderr.write(
2331 '*** no recipients configured so no email will be sent\n'
2332 '*** for %r update %s->%s\n'
2333 % (change.refname, change.old.sha1, change.new.sha1,)
2335 else:
2336 sys.stderr.write('Sending notification emails to: %s\n' % (change.recipients,))
2337 extra_values = {'send_date' : send_date.next()}
2338 mailer.send(
2339 change.generate_email(self, body_filter, extra_values),
2340 change.recipients,
2343 sha1s = []
2344 for sha1 in reversed(list(self.get_new_commits(change))):
2345 if sha1 in unhandled_sha1s:
2346 sha1s.append(sha1)
2347 unhandled_sha1s.remove(sha1)
2349 max_emails = change.environment.maxcommitemails
2350 if max_emails and len(sha1s) > max_emails:
2351 sys.stderr.write(
2352 '*** Too many new commits (%d), not sending commit emails.\n' % len(sha1s)
2353 + '*** Try setting multimailhook.maxCommitEmails to a greater value\n'
2354 + '*** Currently, multimailhook.maxCommitEmails=%d\n' % max_emails
2356 return
2358 for (num, sha1) in enumerate(sha1s):
2359 rev = Revision(change, GitObject(sha1), num=num+1, tot=len(sha1s))
2360 if rev.recipients:
2361 extra_values = {'send_date' : send_date.next()}
2362 mailer.send(
2363 rev.generate_email(self, body_filter, extra_values),
2364 rev.recipients,
2367 # Consistency check:
2368 if unhandled_sha1s:
2369 sys.stderr.write(
2370 'ERROR: No emails were sent for the following new commits:\n'
2371 ' %s\n'
2372 % ('\n '.join(sorted(unhandled_sha1s)),)
2376 def run_as_post_receive_hook(environment, mailer):
2377 changes = []
2378 for line in sys.stdin:
2379 (oldrev, newrev, refname) = line.strip().split(' ', 2)
2380 changes.append(
2381 ReferenceChange.create(environment, oldrev, newrev, refname)
2383 push = Push(changes)
2384 push.send_emails(mailer, body_filter=environment.filter_body)
2387 def run_as_update_hook(environment, mailer, refname, oldrev, newrev):
2388 changes = [
2389 ReferenceChange.create(
2390 environment,
2391 read_git_output(['rev-parse', '--verify', oldrev]),
2392 read_git_output(['rev-parse', '--verify', newrev]),
2393 refname,
2396 push = Push(changes)
2397 push.send_emails(mailer, body_filter=environment.filter_body)
2400 def choose_mailer(config, environment):
2401 mailer = config.get('mailer', default='sendmail')
2403 if mailer == 'smtp':
2404 smtpserver = config.get('smtpserver', default='localhost')
2405 mailer = SMTPMailer(
2406 envelopesender=(environment.get_sender() or environment.get_fromaddr()),
2407 smtpserver=smtpserver,
2409 elif mailer == 'sendmail':
2410 command = config.get('sendmailcommand')
2411 if command:
2412 command = shlex.split(command)
2413 mailer = SendMailer(command=command, envelopesender=environment.get_sender())
2414 else:
2415 sys.stderr.write(
2416 'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n' % mailer
2417 + 'please use one of "smtp" or "sendmail".\n'
2419 sys.exit(1)
2420 return mailer
2423 KNOWN_ENVIRONMENTS = {
2424 'generic' : GenericEnvironmentMixin,
2425 'gitolite' : GitoliteEnvironmentMixin,
2429 def choose_environment(config, osenv=None, env=None, recipients=None):
2430 if not osenv:
2431 osenv = os.environ
2433 environment_mixins = [
2434 ProjectdescEnvironmentMixin,
2435 ConfigMaxlinesEnvironmentMixin,
2436 ComputeFQDNEnvironmentMixin,
2437 ConfigFilterLinesEnvironmentMixin,
2438 PusherDomainEnvironmentMixin,
2439 ConfigOptionsEnvironmentMixin,
2441 environment_kw = {
2442 'osenv' : osenv,
2443 'config' : config,
2446 if not env:
2447 env = config.get('environment')
2449 if not env:
2450 if 'GL_USER' in osenv and 'GL_REPO' in osenv:
2451 env = 'gitolite'
2452 else:
2453 env = 'generic'
2455 environment_mixins.append(KNOWN_ENVIRONMENTS[env])
2457 if recipients:
2458 environment_mixins.insert(0, StaticRecipientsEnvironmentMixin)
2459 environment_kw['refchange_recipients'] = recipients
2460 environment_kw['announce_recipients'] = recipients
2461 environment_kw['revision_recipients'] = recipients
2462 else:
2463 environment_mixins.insert(0, ConfigRecipientsEnvironmentMixin)
2465 environment_klass = type(
2466 'EffectiveEnvironment',
2467 tuple(environment_mixins) + (Environment,),
2470 return environment_klass(**environment_kw)
2473 def main(args):
2474 parser = optparse.OptionParser(
2475 description=__doc__,
2476 usage='%prog [OPTIONS]\n or: %prog [OPTIONS] REFNAME OLDREV NEWREV',
2479 parser.add_option(
2480 '--environment', '--env', action='store', type='choice',
2481 choices=['generic', 'gitolite'], default=None,
2482 help=(
2483 'Choose type of environment is in use. Default is taken from '
2484 'multimailhook.environment if set; otherwise "generic".'
2487 parser.add_option(
2488 '--stdout', action='store_true', default=False,
2489 help='Output emails to stdout rather than sending them.',
2491 parser.add_option(
2492 '--recipients', action='store', default=None,
2493 help='Set list of email recipients for all types of emails.',
2495 parser.add_option(
2496 '--show-env', action='store_true', default=False,
2497 help=(
2498 'Write to stderr the values determined for the environment '
2499 '(intended for debugging purposes).'
2503 (options, args) = parser.parse_args(args)
2505 config = Config('multimailhook')
2507 try:
2508 environment = choose_environment(
2509 config, osenv=os.environ,
2510 env=options.environment,
2511 recipients=options.recipients,
2514 if options.show_env:
2515 sys.stderr.write('Environment values:\n')
2516 for (k,v) in sorted(environment.get_values().items()):
2517 sys.stderr.write(' %s : %r\n' % (k,v))
2518 sys.stderr.write('\n')
2520 if options.stdout:
2521 mailer = OutputMailer(sys.stdout)
2522 else:
2523 mailer = choose_mailer(config, environment)
2525 # Dual mode: if arguments were specified on the command line, run
2526 # like an update hook; otherwise, run as a post-receive hook.
2527 if args:
2528 if len(args) != 3:
2529 parser.error('Need zero or three non-option arguments')
2530 (refname, oldrev, newrev) = args
2531 run_as_update_hook(environment, mailer, refname, oldrev, newrev)
2532 else:
2533 run_as_post_receive_hook(environment, mailer)
2534 except ConfigurationException, e:
2535 sys.exit(str(e))
2538 if __name__ == '__main__':
2539 main(sys.argv[1:])