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
44 See the accompanying README file for the complete documentation.
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
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
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 = """\
107 Content-Type: text/plain; charset=%(charset)s
108 Content-Transfer-Encoding: 8bit
109 Message-ID: %(msgid)s
111 Reply-To: %(reply_to)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 = """\
133 To stop receiving notification emails like this one, please contact
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
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 = """\
233 Subject: %(emailprefix)s%(num)02d/%(tot)02d: %(oneline)s
235 Content-Type: text/plain; charset=%(charset)s
236 Content-Transfer-Encoding: 8bit
238 Reply-To: %(reply_to)s
239 In-Reply-To: %(reply_to_msgid)s
240 References: %(reply_to_msgid)s
242 X-Git-Repo: %(repo_shortname)s
243 X-Git-Refname: %(refname)s
244 X-Git-Reftype: %(refname_type)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):
264 self.retcode = retcode
267 'Command "%s" failed with retcode %s' % (' '.join(cmd), retcode,)
271 class ConfigurationException(Exception):
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.
286 def choose_git_command():
287 """Decide how to invoke git, and record the choice in GIT_CMD."""
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
298 cmd = [GIT_EXECUTABLE, '-c', 'foo.bar=baz', '--version']
300 GIT_CMD = [GIT_EXECUTABLE, '-c', 'i18n.logoutputencoding=%s' % (ENCODING,)]
302 GIT_CMD = [GIT_EXECUTABLE]
305 def read_git_output(args, input=None, keepends=False, **kw):
306 """Read the output of a Git command."""
311 return read_output(GIT_CMD + args, input=input, keepends=keepends, **kw)
314 def read_output(cmd, input=None, keepends=False, **kw):
316 stdin = subprocess.PIPE
319 p = subprocess.Popen(
320 cmd, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw
322 (out, err) = p.communicate(input)
325 raise CommandError(cmd, retcode)
327 out = out.rstrip('\n\r')
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."""
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
357 formataddr((header_encode(name), emailaddr))
358 for name, emailaddr in getaddresses([text])
360 header_name=header_name
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
373 self.section = section
375 self.env = os.environ.copy()
376 self.env['GIT_CONFIG'] = git_config
382 """Split NUL-terminated values."""
384 words = s.split('\0')
385 assert words[-1] == ''
388 def get(self, name, default=None):
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
399 def get_bool(self, name, default=None):
401 value = read_git_output(
402 ['config', '--get', '--bool', '%s.%s' % (self.section, name)],
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
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:
422 # "the section or key is invalid"; i.e., there is no
423 # value for the specified key.
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)
438 return ', '.join(line.strip() for line in lines)
440 def set(self, name, value):
442 ['config', '%s.%s' % (self.section, name), value],
446 def add(self, name, value):
448 ['config', '--add', '%s.%s' % (self.section, name), value],
452 def has_key(self, name):
453 return self.get_all(name, default=None) is not None
455 def unset_all(self, name):
458 ['config', '--unset-all', '%s.%s' % (self.section, name)],
461 except CommandError, e:
463 # The name doesn't exist, which is what we wanted anyway...
468 def set_recipients(self, name, value):
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)."""
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:
494 if index >= max_lines:
495 yield '... %d lines suppressed ...\n' % (index + 1 - max_lines,)
498 def limit_linelength(lines, max_linelength):
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'
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)
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):
529 self.sha1 = self.type = self.commit_sha1 = None
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':
538 self.commit_sha1 = read_git_output(
539 ['rev-parse', '--verify', '%s^0' % (self.sha1,)]
542 # Cannot deref tag to determine commit_sha1
543 self.commit_sha1 = None
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."""
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
561 return hash(self.sha1)
563 def __nonzero__(self):
564 return bool(self.sha1)
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
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
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
600 if self._values is None:
601 self._values = self._compute_values()
603 values = self._values.copy()
605 values.update(extra_values)
608 def expand(self, template, **extra_values):
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):
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)
636 value = value % values
640 'Warning: unknown variable %r in the following line; line skipped:\n'
645 if name.lower() in ADDR_HEADERS:
646 value = addr_header_encode(value, name)
648 value = header_encode(value, name)
649 for splitline in ('%s: %s\n' % (name, value)).splitlines(True):
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
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):
698 for line in self.generate_email_intro():
701 body = self.generate_email_body(push)
702 if body_filter is not None:
703 body = body_filter(body)
707 for line in self.generate_email_footer():
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
718 self.change_type = self.reference_change.change_type
719 self.refname = self.reference_change.refname
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)
747 values['reply_to'] = reply_to
751 def generate_email_header(self, **extra_values):
752 for line in self.expand_header_lines(
753 REVISION_HEADER_TEMPLATE, **extra_values
757 def generate_email_intro(self):
758 for line in self.expand_lines(REVISION_INTRO_TEMPLATE):
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],
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
786 REF_RE = re.compile(r'^refs\/(?P<area>[^\/]+)\/(?P<shortname>.*)$')
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)
799 # The revision type tells us what type the commit is, combined with
800 # the location of the ref we can decide between
805 m = ReferenceChange.REF_RE.match(refname)
807 area = m.group('area')
808 short_refname = m.group('shortname')
811 short_refname = refname
813 if rev.type == 'tag':
815 klass = AnnotatedTagChange
816 elif rev.type == 'commit':
819 klass = NonAnnotatedTagChange
820 elif area == 'heads':
823 elif area == 'remotes':
826 '*** Push-update of tracking branch %r\n'
827 '*** - incomplete email generated.\n'
830 klass = OtherReferenceChange
832 # Some other reference namespace:
834 '*** Push-update of strange reference %r\n'
835 '*** - incomplete email generated.\n'
838 klass = OtherReferenceChange
840 # Anything else (is there anything else?)
842 '*** Unknown type of update to %r (%s)\n'
843 '*** - incomplete email generated.\n'
844 % (refname, rev.type,)
846 klass = OtherReferenceChange
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)
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
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
887 values['oldrev_type'] = self.old.type
889 values['newrev_type'] = self.new.type
891 reply_to = self.environment.get_reply_to_refchange(self)
893 values['reply_to'] = reply_to
897 def get_subject(self):
899 'create' : REF_CREATED_SUBJECT_TEMPLATE,
900 'update' : REF_UPDATED_SUBJECT_TEMPLATE,
901 'delete' : REF_DELETED_SUBJECT_TEMPLATE,
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
914 def generate_email_intro(self):
915 for line in self.expand_lines(REFCHANGE_INTRO_TEMPLATE):
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()."""
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:
932 for line in self.generate_revision_change_summary(push):
935 def generate_email_footer(self):
936 return self.expand_lines(FOOTER_TEMPLATE)
938 def generate_revision_change_log(self, new_commits_list):
941 yield 'Detailed log of new commits:\n\n'
942 for line in read_git_lines(
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
959 sha1s = list(push.get_new_commits(self))
963 Revision(self, GitObject(sha1), num=i+1, tot=tot)
964 for (i, sha1) in enumerate(sha1s)
968 yield self.expand('This %(refname_type)s includes the following new commits:\n')
970 for r in new_revisions:
971 (sha1, subject) = r.rev.get_summary()
973 BRIEF_SUMMARY_TEMPLATE, action='new', text=subject,
976 for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot):
978 for line in self.generate_revision_change_log([r.rev.sha1 for r in new_revisions]):
981 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
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,)
1008 new_commits_list = push.get_new_commits(self)
1010 new_commits_list = []
1011 new_commits = CommitSet(new_commits_list)
1014 discarded_commits = CommitSet(push.get_discarded_commits(self))
1016 discarded_commits = CommitSet([])
1018 if discards and adds:
1019 for (sha1, subject) in discards:
1020 if sha1 in discarded_commits:
1025 BRIEF_SUMMARY_TEMPLATE, action=action,
1026 rev_short=sha1, text=subject,
1028 for (sha1, subject) in adds:
1029 if sha1 in new_commits:
1034 BRIEF_SUMMARY_TEMPLATE, action=action,
1035 rev_short=sha1, text=subject,
1038 for line in self.expand_lines(NON_FF_TEMPLATE):
1042 for (sha1, subject) in discards:
1043 if sha1 in discarded_commits:
1048 BRIEF_SUMMARY_TEMPLATE, action=action,
1049 rev_short=sha1, text=subject,
1052 for line in self.expand_lines(REWIND_ONLY_TEMPLATE):
1056 (sha1, subject) = self.old.get_summary()
1058 BRIEF_SUMMARY_TEMPLATE, action='from',
1059 rev_short=sha1, text=subject,
1061 for (sha1, subject) in adds:
1062 if sha1 in new_commits:
1067 BRIEF_SUMMARY_TEMPLATE, action=action,
1068 rev_short=sha1, text=subject,
1074 for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=len(new_commits)):
1076 for line in self.generate_revision_change_log(new_commits_list):
1079 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
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.
1090 yield 'Summary of changes:\n'
1091 for line in read_git_lines(
1094 + ['%s..%s' % (self.old.commit_sha1, self.new.commit_sha1,)],
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))
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):
1114 for r in discarded_revisions:
1115 (sha1, subject) = r.rev.get_summary()
1117 BRIEF_SUMMARY_TEMPLATE, action='discards', text=subject,
1120 for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE):
1123 elif not self.old.commit_sha1 and not self.new.commit_sha1:
1124 for line in self.expand_lines(NON_COMMIT_UPDATE_TEMPLATE):
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()
1133 BRIEF_SUMMARY_TEMPLATE, action='at',
1134 rev_short=sha1, text=subject,
1138 def generate_update_summary(self, push):
1139 """Called for the change of a pre-existing branch."""
1143 def generate_delete_summary(self, push):
1144 """Called for the deletion of any type of reference."""
1146 (sha1, subject) = self.old.get_summary()
1148 BRIEF_SUMMARY_TEMPLATE, action='was',
1149 rev_short=sha1, text=subject,
1154 class BranchChange(ReferenceChange):
1155 refname_type = 'branch'
1157 def __init__(self, environment, refname, short_refname, old, new, rev):
1158 ReferenceChange.__init__(
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__(
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 = (
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
1190 [tagobject, tagtype, tagger, tagged] = read_git_lines(
1191 ['for-each-ref', '--format=%s' % (self.ANNOTATED_TAG_FORMAT,), self.refname],
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
1203 prevtag = read_git_output(['describe', '--abbrev=0', '%s^' % (self.new,)])
1204 except CommandError:
1207 yield ' replaces %s\n' % (prevtag,)
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,)
1216 # Show the content of the tag message; this might contain a
1217 # change log or release notes so is worth displaying.
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:
1226 if self.show_shortlog and tagtype == 'commit':
1227 # Only commit tags make sense to have rev-list operations
1231 # Show changes since the previous release
1232 revlist = read_git_output(
1233 ['rev-list', '--pretty=short', '%s..%s' % (prevtag, self.new,)],
1237 # No previous tag, show all the changes since time
1239 revlist = read_git_output(
1240 ['rev-list', '--pretty=short', '%s' % (self.new,)],
1243 for line in read_git_lines(['shortlog'], input=revlist, keepends=True):
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):
1255 for line in self.describe_tag(push):
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):
1266 for line in self.describe_tag(push):
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):
1275 yield self.expand(' tag was %(oldrev_short)s\n')
1279 class NonAnnotatedTagChange(ReferenceChange):
1280 refname_type = 'tag'
1282 def __init__(self, environment, refname, short_refname, old, new, rev):
1283 ReferenceChange.__init__(
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):
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):
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):
1308 for line in ReferenceChange.generate_delete_summary(self, push):
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__(
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
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',
1354 def find_sendmail():
1355 for path in SendMailer.SENDMAIL_CANDIDATES:
1356 if os.access(path, os.X_OK):
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
1373 self.command = command[:]
1375 self.command = [self.find_sendmail(), '-oi', '-t']
1378 self.command.extend(['-f', envelopesender])
1380 def send(self, lines, to_addrs):
1382 p = subprocess.Popen(self.command, stdin=subprocess.PIPE)
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'
1392 p.stdin.writelines(lines)
1395 '*** Error while generating commit email\n'
1396 '*** - mail sending aborted.\n'
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:
1413 'fatal: git_multimail: cannot use SMTPMailer without a sender address.\n'
1414 'please set either multimailhook.envelopeSender or user.email\n'
1417 self.envelopesender = envelopesender
1418 self.smtpserver = smtpserver
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))
1429 def send(self, lines, to_addrs):
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))
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):
1453 def send(self, lines, to_addrs):
1454 self.f.write(self.SEPARATOR)
1455 self.f.writelines(lines)
1456 self.f.write(self.SEPARATOR)
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."""
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')
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
1493 Return the absolute path to the Git repository.
1497 Return a string that will be prefixed to every email's
1502 Return the username of the person who pushed the changes.
1503 This value is used in the email body to indicate who
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).
1519 Return the address to be used as the 'From' email address
1520 in the email envelope.
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>'.)
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
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']
1584 self.refchange_showlog = False
1585 self.commitlogopts = ['-C', '--stat', '-p', '--cc']
1587 self.COMPUTED_KEYS = [
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)
1607 return m.group('name')
1611 def get_pusher(self):
1612 raise NotImplementedError()
1614 def get_pusher_email(self):
1617 def get_administrator(self):
1618 return 'the administrator of this repository'
1620 def get_emailprefix(self):
1623 def get_repo_path(self):
1624 if read_git_output(['rev-parse', '--is-bare-repository']) == 'true':
1625 path = get_git_dir()
1627 path = read_git_output(['rev-parse', '--show-toplevel'])
1628 return os.path.abspath(path)
1630 def get_charset(self):
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:
1646 for key in self.COMPUTED_KEYS:
1647 value = getattr(self, 'get_%s' % (key,))()
1648 if value is not None:
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
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."""
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__(
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:
1737 self.maxcommitemails = int(maxcommitemails)
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)
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):
1769 self.config.get('administrator')
1770 or self.get_sender()
1771 or super(ConfigOptionsEnvironmentMixin, self).get_administrator()
1774 def get_repo_shortname(self):
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() + ' '
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')
1795 config = Config('user')
1796 fromname = config.get('name', default='')
1797 fromemail = config.get('email', default='')
1799 return formataddr([fromname, fromemail])
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':
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':
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 ' [...]'
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)
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__(
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)
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__(
1908 emailmaxlines=emailmaxlines,
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']
1922 """Return the fully-qualified domain name for this host.
1924 Return None if it is unavailable or unwanted."""
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__(
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(),
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)
1966 return super(PusherDomainEnvironmentMixin, self).get_pusher_email()
1969 class StaticRecipientsEnvironmentMixin(Environment):
1970 """Set recipients statically based on constructor parameters."""
1974 refchange_recipients, announce_recipients, revision_recipients,
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__(
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',
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."""
2037 retval = config.get_recipients(name)
2038 if retval is not None:
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()
2059 projectdesc = open(os.path.join(git_dir, 'description')).readline().strip()
2060 if projectdesc and not projectdesc.startswith('Unnamed repository'):
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,
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).
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
2110 self.time = time.time()
2113 formatted = formatdate(self.time, True)
2118 class GitoliteEnvironment(
2119 ProjectdescEnvironmentMixin,
2120 ConfigMaxlinesEnvironmentMixin,
2121 ComputeFQDNEnvironmentMixin,
2122 ConfigFilterLinesEnvironmentMixin,
2123 ConfigRecipientsEnvironmentMixin,
2124 PusherDomainEnvironmentMixin,
2125 ConfigOptionsEnvironmentMixin,
2126 GitoliteEnvironmentMixin,
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
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
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
2171 ^other1 ^other2 ... \
2172 ^change1.old ^change2.old ... \
2173 change1.new change2.new ...
2175 The commits removed by this push can be computed by
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
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.
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
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(
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(
2231 for change in self.changes
2232 if change.new.type in ['commit', 'tag']
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:
2246 for change in self.changes
2249 # The SHA-1s of commits referred to by all references in this
2250 # repository *except* updated_refs:
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:
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
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:
2286 for change in self.changes
2289 elif not reference_change.new.commit_sha1:
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:
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
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
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:
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,)
2336 sys.stderr.write('Sending notification emails to: %s\n' % (change.recipients,))
2337 extra_values = {'send_date' : send_date.next()}
2339 change.generate_email(self, body_filter, extra_values),
2344 for sha1 in reversed(list(self.get_new_commits(change))):
2345 if sha1 in unhandled_sha1s:
2347 unhandled_sha1s.remove(sha1)
2349 max_emails = change.environment.maxcommitemails
2350 if max_emails and len(sha1s) > max_emails:
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
2358 for (num, sha1) in enumerate(sha1s):
2359 rev = Revision(change, GitObject(sha1), num=num+1, tot=len(sha1s))
2361 extra_values = {'send_date' : send_date.next()}
2363 rev.generate_email(self, body_filter, extra_values),
2367 # Consistency check:
2370 'ERROR: No emails were sent for the following new commits:\n'
2372 % ('\n '.join(sorted(unhandled_sha1s)),)
2376 def run_as_post_receive_hook(environment, mailer):
2378 for line in sys.stdin:
2379 (oldrev, newrev, refname) = line.strip().split(' ', 2)
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):
2389 ReferenceChange.create(
2391 read_git_output(['rev-parse', '--verify', oldrev]),
2392 read_git_output(['rev-parse', '--verify', newrev]),
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')
2412 command = shlex.split(command)
2413 mailer = SendMailer(command=command, envelopesender=environment.get_sender())
2416 'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n' % mailer
2417 + 'please use one of "smtp" or "sendmail".\n'
2423 KNOWN_ENVIRONMENTS = {
2424 'generic' : GenericEnvironmentMixin,
2425 'gitolite' : GitoliteEnvironmentMixin,
2429 def choose_environment(config, osenv=None, env=None, recipients=None):
2433 environment_mixins = [
2434 ProjectdescEnvironmentMixin,
2435 ConfigMaxlinesEnvironmentMixin,
2436 ComputeFQDNEnvironmentMixin,
2437 ConfigFilterLinesEnvironmentMixin,
2438 PusherDomainEnvironmentMixin,
2439 ConfigOptionsEnvironmentMixin,
2447 env = config.get('environment')
2450 if 'GL_USER' in osenv and 'GL_REPO' in osenv:
2455 environment_mixins.append(KNOWN_ENVIRONMENTS[env])
2458 environment_mixins.insert(0, StaticRecipientsEnvironmentMixin)
2459 environment_kw['refchange_recipients'] = recipients
2460 environment_kw['announce_recipients'] = recipients
2461 environment_kw['revision_recipients'] = recipients
2463 environment_mixins.insert(0, ConfigRecipientsEnvironmentMixin)
2465 environment_klass = type(
2466 'EffectiveEnvironment',
2467 tuple(environment_mixins) + (Environment,),
2470 return environment_klass(**environment_kw)
2474 parser = optparse.OptionParser(
2475 description=__doc__,
2476 usage='%prog [OPTIONS]\n or: %prog [OPTIONS] REFNAME OLDREV NEWREV',
2480 '--environment', '--env', action='store', type='choice',
2481 choices=['generic', 'gitolite'], default=None,
2483 'Choose type of environment is in use. Default is taken from '
2484 'multimailhook.environment if set; otherwise "generic".'
2488 '--stdout', action='store_true', default=False,
2489 help='Output emails to stdout rather than sending them.',
2492 '--recipients', action='store', default=None,
2493 help='Set list of email recipients for all types of emails.',
2496 '--show-env', action='store_true', default=False,
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')
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')
2521 mailer = OutputMailer(sys.stdout)
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.
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)
2533 run_as_post_receive_hook(environment, mailer)
2534 except ConfigurationException, e:
2538 if __name__ == '__main__':