1 #! /usr/bin/env python2
3 # Copyright (c) 2012,2013 Michael Haggerty
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.
58 from email.utils import make_msgid
59 from email.utils import getaddresses
60 from email.utils import formataddr
61 from email.header import Header
63 # Prior to Python 2.5, the email module used different names:
64 from email.Utils import make_msgid
65 from email.Utils import getaddresses
66 from email.Utils import formataddr
67 from email.Header import Header
73 LOGBEGIN = '- Log -----------------------------------------------------------------\n'
74 LOGEND = '-----------------------------------------------------------------------\n'
77 # It is assumed in many places that the encoding is uniformly UTF-8,
78 # so changing these constants is unsupported. But define them here
79 # anyway, to make it easier to find (at least most of) the places
80 # where the encoding is important.
81 (ENCODING, CHARSET) = ('UTF-8', 'utf-8')
84 REF_CREATED_SUBJECT_TEMPLATE = (
85 '%(emailprefix)s%(refname_type)s %(short_refname)s created'
86 ' (now %(newrev_short)s)'
88 REF_UPDATED_SUBJECT_TEMPLATE = (
89 '%(emailprefix)s%(refname_type)s %(short_refname)s updated'
90 ' (%(oldrev_short)s -> %(newrev_short)s)'
92 REF_DELETED_SUBJECT_TEMPLATE = (
93 '%(emailprefix)s%(refname_type)s %(short_refname)s deleted'
94 ' (was %(oldrev_short)s)'
97 REFCHANGE_HEADER_TEMPLATE = """\
101 Content-Type: text/plain; charset=%(charset)s
102 Content-Transfer-Encoding: 8bit
103 Message-ID: %(msgid)s
105 Reply-To: %(reply_to)s
106 X-Git-Repo: %(repo_shortname)s
107 X-Git-Refname: %(refname)s
108 X-Git-Reftype: %(refname_type)s
109 X-Git-Oldrev: %(oldrev)s
110 X-Git-Newrev: %(newrev)s
111 Auto-Submitted: auto-generated
114 REFCHANGE_INTRO_TEMPLATE = """\
115 This is an automated email from the git hooks/post-receive script.
117 %(pusher)s pushed a change to %(refname_type)s %(short_refname)s
118 in repository %(repo_shortname)s.
123 FOOTER_TEMPLATE = """\
126 To stop receiving notification emails like this one, please contact
131 REWIND_ONLY_TEMPLATE = """\
132 This update removed existing revisions from the reference, leaving the
133 reference pointing at a previous point in the repository history.
135 * -- * -- N %(refname)s (%(newrev_short)s)
137 O -- O -- O (%(oldrev_short)s)
139 Any revisions marked "omits" are not gone; other references still
140 refer to them. Any revisions marked "discards" are gone forever.
144 NON_FF_TEMPLATE = """\
145 This update added new revisions after undoing existing revisions.
146 That is to say, some revisions that were in the old version of the
147 %(refname_type)s are not in the new version. This situation occurs
148 when a user --force pushes a change and generates a repository
149 containing something like this:
151 * -- * -- B -- O -- O -- O (%(oldrev_short)s)
153 N -- N -- N %(refname)s (%(newrev_short)s)
155 You should already have received notification emails for all of the O
156 revisions, and so the following emails describe only the N revisions
157 from the common base, B.
159 Any revisions marked "omits" are not gone; other references still
160 refer to them. Any revisions marked "discards" are gone forever.
164 NO_NEW_REVISIONS_TEMPLATE = """\
165 No new revisions were added by this update.
169 DISCARDED_REVISIONS_TEMPLATE = """\
170 This change permanently discards the following revisions:
174 NO_DISCARDED_REVISIONS_TEMPLATE = """\
175 The revisions that were on this %(refname_type)s are still contained in
176 other references; therefore, this change does not discard any commits
181 NEW_REVISIONS_TEMPLATE = """\
182 The %(tot)s revisions listed above as "new" are entirely new to this
183 repository and will be described in separate emails. The revisions
184 listed as "adds" were already present in the repository and have only
185 been added to this reference.
190 TAG_CREATED_TEMPLATE = """\
191 at %(newrev_short)-9s (%(newrev_type)s)
195 TAG_UPDATED_TEMPLATE = """\
196 *** WARNING: tag %(short_refname)s was modified! ***
198 from %(oldrev_short)-9s (%(oldrev_type)s)
199 to %(newrev_short)-9s (%(newrev_type)s)
203 TAG_DELETED_TEMPLATE = """\
204 *** WARNING: tag %(short_refname)s was deleted! ***
209 # The template used in summary tables. It looks best if this uses the
210 # same alignment as TAG_CREATED_TEMPLATE and TAG_UPDATED_TEMPLATE.
211 BRIEF_SUMMARY_TEMPLATE = """\
212 %(action)10s %(rev_short)-9s %(text)s
216 NON_COMMIT_UPDATE_TEMPLATE = """\
217 This is an unusual reference change because the reference did not
218 refer to a commit either before or after the change. We do not know
219 how to provide full information about this reference change.
223 REVISION_HEADER_TEMPLATE = """\
225 Subject: %(emailprefix)s%(num)02d/%(tot)02d: %(oneline)s
227 Content-Type: text/plain; charset=%(charset)s
228 Content-Transfer-Encoding: 8bit
230 Reply-To: %(reply_to)s
231 In-Reply-To: %(reply_to_msgid)s
232 References: %(reply_to_msgid)s
233 X-Git-Repo: %(repo_shortname)s
234 X-Git-Refname: %(refname)s
235 X-Git-Reftype: %(refname_type)s
237 Auto-Submitted: auto-generated
240 REVISION_INTRO_TEMPLATE = """\
241 This is an automated email from the git hooks/post-receive script.
243 %(pusher)s pushed a commit to %(refname_type)s %(short_refname)s
244 in repository %(repo_shortname)s.
249 REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE
252 class CommandError(Exception):
253 def __init__(self, cmd, retcode):
255 self.retcode = retcode
258 'Command "%s" failed with retcode %s' % (' '.join(cmd), retcode,)
262 class ConfigurationException(Exception):
266 def read_git_output(args, input=None, keepends=False, **kw):
267 """Read the output of a Git command."""
270 ['git', '-c', 'i18n.logoutputencoding=%s' % (ENCODING,)] + args,
271 input=input, keepends=keepends, **kw
275 def read_output(cmd, input=None, keepends=False, **kw):
277 stdin = subprocess.PIPE
280 p = subprocess.Popen(
281 cmd, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw
283 (out, err) = p.communicate(input)
286 raise CommandError(cmd, retcode)
288 out = out.rstrip('\n\r')
292 def read_git_lines(args, keepends=False, **kw):
293 """Return the lines output by Git command.
295 Return as single lines, with newlines stripped off."""
297 return read_git_output(args, keepends=True, **kw).splitlines(keepends)
300 class Config(object):
301 def __init__(self, section, git_config=None):
302 """Represent a section of the git configuration.
304 If git_config is specified, it is passed to "git config" in
305 the GIT_CONFIG environment variable, meaning that "git config"
306 will read the specified path rather than the Git default
309 self.section = section
311 self.env = os.environ.copy()
312 self.env['GIT_CONFIG'] = git_config
318 """Split NUL-terminated values."""
320 words = s.split('\0')
321 assert words[-1] == ''
324 def get(self, name, default=None):
326 values = self._split(read_git_output(
327 ['config', '--get', '--null', '%s.%s' % (self.section, name)],
328 env=self.env, keepends=True,
330 assert len(values) == 1
335 def get_bool(self, name, default=None):
337 value = read_git_output(
338 ['config', '--get', '--bool', '%s.%s' % (self.section, name)],
343 return value == 'true'
345 def get_all(self, name, default=None):
346 """Read a (possibly multivalued) setting from the configuration.
348 Return the result as a list of values, or default if the name
352 return self._split(read_git_output(
353 ['config', '--get-all', '--null', '%s.%s' % (self.section, name)],
354 env=self.env, keepends=True,
356 except CommandError, e:
358 # "the section or key is invalid"; i.e., there is no
359 # value for the specified key.
364 def get_recipients(self, name, default=None):
365 """Read a recipients list from the configuration.
367 Return the result as a comma-separated list of email
368 addresses, or default if the option is unset. If the setting
369 has multiple values, concatenate them with comma separators."""
371 lines = self.get_all(name, default=None)
374 return ', '.join(line.strip() for line in lines)
376 def set(self, name, value):
378 ['config', '%s.%s' % (self.section, name), value],
382 def add(self, name, value):
384 ['config', '--add', '%s.%s' % (self.section, name), value],
388 def has_key(self, name):
389 return self.get_all(name, default=None) is not None
391 def unset_all(self, name):
394 ['config', '--unset-all', '%s.%s' % (self.section, name)],
397 except CommandError, e:
399 # The name doesn't exist, which is what we wanted anyway...
404 def set_recipients(self, name, value):
406 for pair in getaddresses([value]):
407 self.add(name, formataddr(pair))
410 def generate_summaries(*log_args):
411 """Generate a brief summary for each revision requested.
413 log_args are strings that will be passed directly to "git log" as
414 revision selectors. Iterate over (sha1_short, subject) for each
415 commit specified by log_args (subject is the first line of the
416 commit message as a string without EOLs)."""
419 'log', '--abbrev', '--format=%h %s',
420 ] + list(log_args) + ['--']
421 for line in read_git_lines(cmd):
422 yield tuple(line.split(' ', 1))
425 def limit_lines(lines, max_lines):
426 for (index, line) in enumerate(lines):
427 if index < max_lines:
430 if index >= max_lines:
431 yield '... %d lines suppressed ...\n' % (index + 1 - max_lines,)
434 def limit_linelength(lines, max_linelength):
436 # Don't forget that lines always include a trailing newline.
437 if len(line) > max_linelength + 1:
438 line = line[:max_linelength - 7] + ' [...]\n'
442 class CommitSet(object):
443 """A (constant) set of object names.
445 The set should be initialized with full SHA1 object names. The
446 __contains__() method returns True iff its argument is an
447 abbreviation of any the names in the set."""
449 def __init__(self, names):
450 self._names = sorted(names)
453 return len(self._names)
455 def __contains__(self, sha1_abbrev):
456 """Return True iff this set contains sha1_abbrev (which might be abbreviated)."""
458 i = bisect.bisect_left(self._names, sha1_abbrev)
459 return i < len(self) and self._names[i].startswith(sha1_abbrev)
462 class GitObject(object):
463 def __init__(self, sha1, type=None):
465 self.sha1 = self.type = self.commit_sha1 = None
468 self.type = type or read_git_output(['cat-file', '-t', self.sha1])
470 if self.type == 'commit':
471 self.commit_sha1 = self.sha1
472 elif self.type == 'tag':
474 self.commit_sha1 = read_git_output(
475 ['rev-parse', '--verify', '%s^0' % (self.sha1,)]
478 # Cannot deref tag to determine commit_sha1
479 self.commit_sha1 = None
481 self.commit_sha1 = None
483 self.short = read_git_output(['rev-parse', '--short', sha1])
485 def get_summary(self):
486 """Return (sha1_short, subject) for this commit."""
489 raise ValueError('Empty commit has no summary')
491 return iter(generate_summaries('--no-walk', self.sha1)).next()
493 def __eq__(self, other):
494 return isinstance(other, GitObject) and self.sha1 == other.sha1
497 return hash(self.sha1)
499 def __nonzero__(self):
500 return bool(self.sha1)
503 return self.sha1 or ZEROS
506 class Change(object):
507 """A Change that has been made to the Git repository.
509 Abstract class from which both Revisions and ReferenceChanges are
510 derived. A Change knows how to generate a notification email
511 describing itself."""
513 def __init__(self, environment):
514 self.environment = environment
517 def _compute_values(self):
518 """Return a dictionary {keyword : expansion} for this Change.
520 Derived classes overload this method to add more entries to
521 the return value. This method is used internally by
522 get_values(). The return value should always be a new
525 return self.environment.get_values()
527 def get_values(self, **extra_values):
528 """Return a dictionary {keyword : expansion} for this Change.
530 Return a dictionary mapping keywords to the values that they
531 should be expanded to for this Change (used when interpolating
532 template strings). If any keyword arguments are supplied, add
533 those to the return value as well. The return value is always
536 if self._values is None:
537 self._values = self._compute_values()
539 values = self._values.copy()
541 values.update(extra_values)
544 def expand(self, template, **extra_values):
547 Expand the template (which should be a string) using string
548 interpolation of the values for this Change. If any keyword
549 arguments are provided, also include those in the keywords
550 available for interpolation."""
552 return template % self.get_values(**extra_values)
554 def expand_lines(self, template, **extra_values):
555 """Break template into lines and expand each line."""
557 values = self.get_values(**extra_values)
558 for line in template.splitlines(True):
561 def expand_header_lines(self, template, **extra_values):
562 """Break template into lines and expand each line as an RFC 2822 header.
564 Encode values and split up lines that are too long. Silently
565 skip lines that contain references to unknown variables."""
567 values = self.get_values(**extra_values)
568 for line in template.splitlines():
569 (name, value) = line.split(':', 1)
572 value = value % values
576 'Warning: unknown variable %r in the following line; line skipped:\n'
582 h = Header(value, header_name=name)
583 except UnicodeDecodeError:
584 h = Header(value, header_name=name, charset=CHARSET, errors='replace')
585 for splitline in ('%s: %s\n' % (name, h.encode(),)).splitlines(True):
588 def generate_email_header(self):
589 """Generate the RFC 2822 email headers for this Change, a line at a time.
591 The output should not include the trailing blank line."""
593 raise NotImplementedError()
595 def generate_email_intro(self):
596 """Generate the email intro for this Change, a line at a time.
598 The output will be used as the standard boilerplate at the top
599 of the email body."""
601 raise NotImplementedError()
603 def generate_email_body(self):
604 """Generate the main part of the email body, a line at a time.
606 The text in the body might be truncated after a specified
607 number of lines (see multimailhook.emailmaxlines)."""
609 raise NotImplementedError()
611 def generate_email_footer(self):
612 """Generate the footer of the email, a line at a time.
614 The footer is always included, irrespective of
615 multimailhook.emailmaxlines."""
617 raise NotImplementedError()
619 def generate_email(self, push, body_filter=None):
620 """Generate an email describing this change.
622 Iterate over the lines (including the header lines) of an
623 email describing this change. If body_filter is not None,
624 then use it to filter the lines that are intended for the
627 for line in self.generate_email_header():
630 for line in self.generate_email_intro():
633 body = self.generate_email_body(push)
634 if body_filter is not None:
635 body = body_filter(body)
639 for line in self.generate_email_footer():
643 class Revision(Change):
644 """A Change consisting of a single git commit."""
646 def __init__(self, reference_change, rev, num, tot):
647 Change.__init__(self, reference_change.environment)
648 self.reference_change = reference_change
650 self.change_type = self.reference_change.change_type
651 self.refname = self.reference_change.refname
654 self.author = read_git_output(['log', '--no-walk', '--format=%aN <%aE>', self.rev.sha1])
655 self.recipients = self.environment.get_revision_recipients(self)
657 def _compute_values(self):
658 values = Change._compute_values(self)
660 oneline = read_git_output(
661 ['log', '--format=%s', '--no-walk', self.rev.sha1]
664 values['rev'] = self.rev.sha1
665 values['rev_short'] = self.rev.short
666 values['change_type'] = self.change_type
667 values['refname'] = self.refname
668 values['short_refname'] = self.reference_change.short_refname
669 values['refname_type'] = self.reference_change.refname_type
670 values['reply_to_msgid'] = self.reference_change.msgid
671 values['num'] = self.num
672 values['tot'] = self.tot
673 values['recipients'] = self.recipients
674 values['oneline'] = oneline
675 values['author'] = self.author
677 reply_to = self.environment.get_reply_to_commit(self)
679 values['reply_to'] = reply_to
683 def generate_email_header(self):
684 for line in self.expand_header_lines(REVISION_HEADER_TEMPLATE):
687 def generate_email_intro(self):
688 for line in self.expand_lines(REVISION_INTRO_TEMPLATE):
691 def generate_email_body(self, push):
692 """Show this revision."""
694 return read_git_lines(
697 '--stat', '-p', '--cc',
703 def generate_email_footer(self):
704 return self.expand_lines(REVISION_FOOTER_TEMPLATE)
707 class ReferenceChange(Change):
708 """A Change to a Git reference.
710 An abstract class representing a create, update, or delete of a
711 Git reference. Derived classes handle specific types of reference
712 (e.g., tags vs. branches). These classes generate the main
713 reference change email summarizing the reference change and
714 whether it caused any any commits to be added or removed.
716 ReferenceChange objects are usually created using the static
717 create() method, which has the logic to decide which derived class
720 REF_RE = re.compile(r'^refs\/(?P<area>[^\/]+)\/(?P<shortname>.*)$')
723 def create(environment, oldrev, newrev, refname):
724 """Return a ReferenceChange object representing the change.
726 Return an object that represents the type of change that is being
727 made. oldrev and newrev should be SHA1s or ZEROS."""
729 old = GitObject(oldrev)
730 new = GitObject(newrev)
733 # The revision type tells us what type the commit is, combined with
734 # the location of the ref we can decide between
739 m = ReferenceChange.REF_RE.match(refname)
741 area = m.group('area')
742 short_refname = m.group('shortname')
745 short_refname = refname
747 if rev.type == 'tag':
749 klass = AnnotatedTagChange
750 elif rev.type == 'commit':
753 klass = NonAnnotatedTagChange
754 elif area == 'heads':
757 elif area == 'remotes':
760 '*** Push-update of tracking branch %r\n'
761 '*** - incomplete email generated.\n'
764 klass = OtherReferenceChange
766 # Some other reference namespace:
768 '*** Push-update of strange reference %r\n'
769 '*** - incomplete email generated.\n'
772 klass = OtherReferenceChange
774 # Anything else (is there anything else?)
776 '*** Unknown type of update to %r (%s)\n'
777 '*** - incomplete email generated.\n'
778 % (refname, rev.type,)
780 klass = OtherReferenceChange
784 refname=refname, short_refname=short_refname,
785 old=old, new=new, rev=rev,
788 def __init__(self, environment, refname, short_refname, old, new, rev):
789 Change.__init__(self, environment)
791 (False, True) : 'create',
792 (True, True) : 'update',
793 (True, False) : 'delete',
794 }[bool(old), bool(new)]
795 self.refname = refname
796 self.short_refname = short_refname
800 self.msgid = make_msgid()
801 self.diffopts = environment.diffopts
802 self.logopts = environment.logopts
803 self.showlog = environment.refchange_showlog
805 def _compute_values(self):
806 values = Change._compute_values(self)
808 values['change_type'] = self.change_type
809 values['refname_type'] = self.refname_type
810 values['refname'] = self.refname
811 values['short_refname'] = self.short_refname
812 values['msgid'] = self.msgid
813 values['recipients'] = self.recipients
814 values['oldrev'] = str(self.old)
815 values['oldrev_short'] = self.old.short
816 values['newrev'] = str(self.new)
817 values['newrev_short'] = self.new.short
820 values['oldrev_type'] = self.old.type
822 values['newrev_type'] = self.new.type
824 reply_to = self.environment.get_reply_to_refchange(self)
826 values['reply_to'] = reply_to
830 def get_subject(self):
832 'create' : REF_CREATED_SUBJECT_TEMPLATE,
833 'update' : REF_UPDATED_SUBJECT_TEMPLATE,
834 'delete' : REF_DELETED_SUBJECT_TEMPLATE,
836 return self.expand(template)
838 def generate_email_header(self):
839 for line in self.expand_header_lines(
840 REFCHANGE_HEADER_TEMPLATE, subject=self.get_subject(),
844 def generate_email_intro(self):
845 for line in self.expand_lines(REFCHANGE_INTRO_TEMPLATE):
848 def generate_email_body(self, push):
849 """Call the appropriate body-generation routine.
851 Call one of generate_create_summary() /
852 generate_update_summary() / generate_delete_summary()."""
855 'create' : self.generate_create_summary,
856 'delete' : self.generate_delete_summary,
857 'update' : self.generate_update_summary,
858 }[self.change_type](push)
859 for line in change_summary:
862 for line in self.generate_revision_change_summary(push):
865 def generate_email_footer(self):
866 return self.expand_lines(FOOTER_TEMPLATE)
868 def generate_revision_change_log(self, new_commits_list):
871 yield 'Detailed log of new commits:\n\n'
872 for line in read_git_lines(
881 def generate_revision_change_summary(self, push):
882 """Generate a summary of the revisions added/removed by this change."""
884 if self.new.commit_sha1 and not self.old.commit_sha1:
885 # A new reference was created. List the new revisions
886 # brought by the new reference (i.e., those revisions that
887 # were not in the repository before this reference
889 sha1s = list(push.get_new_commits(self))
893 Revision(self, GitObject(sha1), num=i+1, tot=tot)
894 for (i, sha1) in enumerate(sha1s)
898 yield self.expand('This %(refname_type)s includes the following new commits:\n')
900 for r in new_revisions:
901 (sha1, subject) = r.rev.get_summary()
903 BRIEF_SUMMARY_TEMPLATE, action='new', text=subject,
906 for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot):
908 for line in self.generate_revision_change_log([r.rev.sha1 for r in new_revisions]):
911 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
914 elif self.new.commit_sha1 and self.old.commit_sha1:
915 # A reference was changed to point at a different commit.
916 # List the revisions that were removed and/or added *from
917 # that reference* by this reference change, along with a
918 # diff between the trees for its old and new values.
920 # List of the revisions that were added to the branch by
921 # this update. Note this list can include revisions that
922 # have already had notification emails; we want such
923 # revisions in the summary even though we will not send
924 # new notification emails for them.
925 adds = list(generate_summaries(
926 '--topo-order', '--reverse', '%s..%s'
927 % (self.old.commit_sha1, self.new.commit_sha1,)
930 # List of the revisions that were removed from the branch
931 # by this update. This will be empty except for
932 # non-fast-forward updates.
933 discards = list(generate_summaries(
934 '%s..%s' % (self.new.commit_sha1, self.old.commit_sha1,)
938 new_commits_list = push.get_new_commits(self)
940 new_commits_list = []
941 new_commits = CommitSet(new_commits_list)
944 discarded_commits = CommitSet(push.get_discarded_commits(self))
946 discarded_commits = CommitSet([])
948 if discards and adds:
949 for (sha1, subject) in discards:
950 if sha1 in discarded_commits:
955 BRIEF_SUMMARY_TEMPLATE, action=action,
956 rev_short=sha1, text=subject,
958 for (sha1, subject) in adds:
959 if sha1 in new_commits:
964 BRIEF_SUMMARY_TEMPLATE, action=action,
965 rev_short=sha1, text=subject,
968 for line in self.expand_lines(NON_FF_TEMPLATE):
972 for (sha1, subject) in discards:
973 if sha1 in discarded_commits:
978 BRIEF_SUMMARY_TEMPLATE, action=action,
979 rev_short=sha1, text=subject,
982 for line in self.expand_lines(REWIND_ONLY_TEMPLATE):
986 (sha1, subject) = self.old.get_summary()
988 BRIEF_SUMMARY_TEMPLATE, action='from',
989 rev_short=sha1, text=subject,
991 for (sha1, subject) in adds:
992 if sha1 in new_commits:
997 BRIEF_SUMMARY_TEMPLATE, action=action,
998 rev_short=sha1, text=subject,
1004 for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=len(new_commits)):
1006 for line in self.generate_revision_change_log(new_commits_list):
1009 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
1012 # The diffstat is shown from the old revision to the new
1013 # revision. This is to show the truth of what happened in
1014 # this change. There's no point showing the stat from the
1015 # base to the new revision because the base is effectively a
1016 # random revision at this point - the user will be interested
1017 # in what this revision changed - including the undoing of
1018 # previous revisions in the case of non-fast-forward updates.
1020 yield 'Summary of changes:\n'
1021 for line in read_git_lines(
1024 + ['%s..%s' % (self.old.commit_sha1, self.new.commit_sha1,)],
1029 elif self.old.commit_sha1 and not self.new.commit_sha1:
1030 # A reference was deleted. List the revisions that were
1031 # removed from the repository by this reference change.
1033 sha1s = list(push.get_discarded_commits(self))
1035 discarded_revisions = [
1036 Revision(self, GitObject(sha1), num=i+1, tot=tot)
1037 for (i, sha1) in enumerate(sha1s)
1040 if discarded_revisions:
1041 for line in self.expand_lines(DISCARDED_REVISIONS_TEMPLATE):
1044 for r in discarded_revisions:
1045 (sha1, subject) = r.rev.get_summary()
1047 BRIEF_SUMMARY_TEMPLATE, action='discards', text=subject,
1050 for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE):
1053 elif not self.old.commit_sha1 and not self.new.commit_sha1:
1054 for line in self.expand_lines(NON_COMMIT_UPDATE_TEMPLATE):
1057 def generate_create_summary(self, push):
1058 """Called for the creation of a reference."""
1060 # This is a new reference and so oldrev is not valid
1061 (sha1, subject) = self.new.get_summary()
1063 BRIEF_SUMMARY_TEMPLATE, action='at',
1064 rev_short=sha1, text=subject,
1068 def generate_update_summary(self, push):
1069 """Called for the change of a pre-existing branch."""
1073 def generate_delete_summary(self, push):
1074 """Called for the deletion of any type of reference."""
1076 (sha1, subject) = self.old.get_summary()
1078 BRIEF_SUMMARY_TEMPLATE, action='was',
1079 rev_short=sha1, text=subject,
1084 class BranchChange(ReferenceChange):
1085 refname_type = 'branch'
1087 def __init__(self, environment, refname, short_refname, old, new, rev):
1088 ReferenceChange.__init__(
1090 refname=refname, short_refname=short_refname,
1091 old=old, new=new, rev=rev,
1093 self.recipients = environment.get_refchange_recipients(self)
1096 class AnnotatedTagChange(ReferenceChange):
1097 refname_type = 'annotated tag'
1099 def __init__(self, environment, refname, short_refname, old, new, rev):
1100 ReferenceChange.__init__(
1102 refname=refname, short_refname=short_refname,
1103 old=old, new=new, rev=rev,
1105 self.recipients = environment.get_announce_recipients(self)
1106 self.show_shortlog = environment.announce_show_shortlog
1108 ANNOTATED_TAG_FORMAT = (
1115 def describe_tag(self, push):
1116 """Describe the new value of an annotated tag."""
1118 # Use git for-each-ref to pull out the individual fields from
1120 [tagobject, tagtype, tagger, tagged] = read_git_lines(
1121 ['for-each-ref', '--format=%s' % (self.ANNOTATED_TAG_FORMAT,), self.refname],
1125 BRIEF_SUMMARY_TEMPLATE, action='tagging',
1126 rev_short=tagobject, text='(%s)' % (tagtype,),
1128 if tagtype == 'commit':
1129 # If the tagged object is a commit, then we assume this is a
1130 # release, and so we calculate which tag this tag is
1133 prevtag = read_git_output(['describe', '--abbrev=0', '%s^' % (self.new,)])
1134 except CommandError:
1137 yield ' replaces %s\n' % (prevtag,)
1140 yield ' length %s bytes\n' % (read_git_output(['cat-file', '-s', tagobject]),)
1142 yield ' tagged by %s\n' % (tagger,)
1143 yield ' on %s\n' % (tagged,)
1146 # Show the content of the tag message; this might contain a
1147 # change log or release notes so is worth displaying.
1149 contents = list(read_git_lines(['cat-file', 'tag', self.new.sha1], keepends=True))
1150 contents = contents[contents.index('\n') + 1:]
1151 if contents and contents[-1][-1:] != '\n':
1152 contents.append('\n')
1153 for line in contents:
1156 if self.show_shortlog and tagtype == 'commit':
1157 # Only commit tags make sense to have rev-list operations
1161 # Show changes since the previous release
1162 revlist = read_git_output(
1163 ['rev-list', '--pretty=short', '%s..%s' % (prevtag, self.new,)],
1167 # No previous tag, show all the changes since time
1169 revlist = read_git_output(
1170 ['rev-list', '--pretty=short', '%s' % (self.new,)],
1173 for line in read_git_lines(['shortlog'], input=revlist, keepends=True):
1179 def generate_create_summary(self, push):
1180 """Called for the creation of an annotated tag."""
1182 for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1185 for line in self.describe_tag(push):
1188 def generate_update_summary(self, push):
1189 """Called for the update of an annotated tag.
1191 This is probably a rare event and may not even be allowed."""
1193 for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1196 for line in self.describe_tag(push):
1199 def generate_delete_summary(self, push):
1200 """Called when a non-annotated reference is updated."""
1202 for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1205 yield self.expand(' tag was %(oldrev_short)s\n')
1209 class NonAnnotatedTagChange(ReferenceChange):
1210 refname_type = 'tag'
1212 def __init__(self, environment, refname, short_refname, old, new, rev):
1213 ReferenceChange.__init__(
1215 refname=refname, short_refname=short_refname,
1216 old=old, new=new, rev=rev,
1218 self.recipients = environment.get_refchange_recipients(self)
1220 def generate_create_summary(self, push):
1221 """Called for the creation of an annotated tag."""
1223 for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1226 def generate_update_summary(self, push):
1227 """Called when a non-annotated reference is updated."""
1229 for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1232 def generate_delete_summary(self, push):
1233 """Called when a non-annotated reference is updated."""
1235 for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1238 for line in ReferenceChange.generate_delete_summary(self, push):
1242 class OtherReferenceChange(ReferenceChange):
1243 refname_type = 'reference'
1245 def __init__(self, environment, refname, short_refname, old, new, rev):
1246 # We use the full refname as short_refname, because otherwise
1247 # the full name of the reference would not be obvious from the
1248 # text of the email.
1249 ReferenceChange.__init__(
1251 refname=refname, short_refname=refname,
1252 old=old, new=new, rev=rev,
1254 self.recipients = environment.get_refchange_recipients(self)
1257 class Mailer(object):
1258 """An object that can send emails."""
1260 def send(self, lines, to_addrs):
1261 """Send an email consisting of lines.
1263 lines must be an iterable over the lines constituting the
1264 header and body of the email. to_addrs is a list of recipient
1265 addresses (can be needed even if lines already contains a
1266 "To:" field). It can be either a string (comma-separated list
1267 of email addresses) or a Python list of individual email
1272 raise NotImplementedError()
1275 class SendMailer(Mailer):
1276 """Send emails using 'sendmail -t'."""
1278 SENDMAIL_CANDIDATES = [
1279 '/usr/sbin/sendmail',
1280 '/usr/lib/sendmail',
1284 def find_sendmail():
1285 for path in SendMailer.SENDMAIL_CANDIDATES:
1286 if os.access(path, os.X_OK):
1289 raise ConfigurationException(
1290 'No sendmail executable found. '
1291 'Try setting multimailhook.sendmailCommand.'
1294 def __init__(self, command=None, envelopesender=None):
1295 """Construct a SendMailer instance.
1297 command should be the command and arguments used to invoke
1298 sendmail, as a list of strings. If an envelopesender is
1299 provided, it will also be passed to the command, via '-f
1303 self.command = command[:]
1305 self.command = [self.find_sendmail(), '-t']
1308 self.command.extend(['-f', envelopesender])
1310 def send(self, lines, to_addrs):
1312 p = subprocess.Popen(self.command, stdin=subprocess.PIPE)
1315 '*** Cannot execute command: %s\n' % ' '.join(self.command)
1316 + '*** %s\n' % str(e)
1317 + '*** Try setting multimailhook.mailer to "smtp"\n'
1318 '*** to send emails without using the sendmail command.\n'
1322 p.stdin.writelines(lines)
1325 '*** Error while generating commit email\n'
1326 '*** - mail sending aborted.\n'
1334 raise CommandError(self.command, retcode)
1337 class SMTPMailer(Mailer):
1338 """Send emails using Python's smtplib."""
1340 def __init__(self, envelopesender, smtpserver):
1341 if not envelopesender:
1343 'fatal: git_multimail: cannot use SMTPMailer without a sender address.\n'
1344 'please set either multimailhook.envelopeSender or user.email\n'
1347 self.envelopesender = envelopesender
1348 self.smtpserver = smtpserver
1350 self.smtp = smtplib.SMTP(self.smtpserver)
1351 except Exception, e:
1352 sys.stderr.write('*** Error establishing SMTP connection to %s***\n' % self.smtpserver)
1353 sys.stderr.write('*** %s\n' % str(e))
1359 def send(self, lines, to_addrs):
1361 msg = ''.join(lines)
1362 # turn comma-separated list into Python list if needed.
1363 if isinstance(to_addrs, basestring):
1364 to_addrs = [email for (name, email) in getaddresses([to_addrs])]
1365 self.smtp.sendmail(self.envelopesender, to_addrs, msg)
1366 except Exception, e:
1367 sys.stderr.write('*** Error sending email***\n')
1368 sys.stderr.write('*** %s\n' % str(e))
1373 class OutputMailer(Mailer):
1374 """Write emails to an output stream, bracketed by lines of '=' characters.
1376 This is intended for debugging purposes."""
1378 SEPARATOR = '=' * 75 + '\n'
1380 def __init__(self, f):
1383 def send(self, lines, to_addrs):
1384 self.f.write(self.SEPARATOR)
1385 self.f.writelines(lines)
1386 self.f.write(self.SEPARATOR)
1390 """Determine GIT_DIR.
1392 Determine GIT_DIR either from the GIT_DIR environment variable or
1393 from the working directory, using Git's usual rules."""
1396 return read_git_output(['rev-parse', '--git-dir'])
1397 except CommandError:
1398 sys.stderr.write('fatal: git_multimail: not in a git directory\n')
1402 class Environment(object):
1403 """Describes the environment in which the push is occurring.
1405 An Environment object encapsulates information about the local
1406 environment. For example, it knows how to determine:
1408 * the name of the repository to which the push occurred
1410 * what user did the push
1412 * what users want to be informed about various types of changes.
1414 An Environment object is expected to have the following methods:
1416 get_repo_shortname()
1418 Return a short name for the repository, for display
1423 Return the absolute path to the Git repository.
1427 Return a string that will be prefixed to every email's
1432 Return the username of the person who pushed the changes.
1433 This value is used in the email body to indicate who
1436 get_pusher_email() (may return None)
1438 Return the email address of the person who pushed the
1439 changes. The value should be a single RFC 2822 email
1440 address as a string; e.g., "Joe User <user@example.com>"
1441 if available, otherwise "user@example.com". If set, the
1442 value is used as the Reply-To address for refchange
1443 emails. If it is impossible to determine the pusher's
1444 email, this attribute should be set to None (in which case
1445 no Reply-To header will be output).
1449 Return the address to be used as the 'From' email address
1450 in the email envelope.
1454 Return the 'From' email address used in the email 'From:'
1455 headers. (May be a full RFC 2822 email address like 'Joe
1456 User <user@example.com>'.)
1460 Return the name and/or email of the repository
1461 administrator. This value is used in the footer as the
1462 person to whom requests to be removed from the
1463 notification list should be sent. Ideally, it should
1464 include a valid email address.
1466 get_reply_to_refchange()
1467 get_reply_to_commit()
1469 Return the address to use in the email "Reply-To" header,
1470 as a string. These can be an RFC 2822 email address, or
1471 None to omit the "Reply-To" header.
1472 get_reply_to_refchange() is used for refchange emails;
1473 get_reply_to_commit() is used for individual commit
1476 They should also define the following attributes:
1478 announce_show_shortlog (bool)
1480 True iff announce emails should include a shortlog.
1482 refchange_showlog (bool)
1484 True iff refchanges emails should include a detailed log.
1486 diffopts (list of strings)
1488 The options that should be passed to 'git diff' for the
1489 summary email. The value should be a list of strings
1490 representing words to be passed to the command.
1492 logopts (list of strings)
1494 Analogous to diffopts, but contains options passed to
1495 'git log' when generating the detailed log for a set of
1496 commits (see refchange_showlog)
1500 REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$')
1502 def __init__(self, osenv=None):
1503 self.osenv = osenv or os.environ
1504 self.announce_show_shortlog = False
1505 self.maxcommitemails = 500
1506 self.diffopts = ['--stat', '--summary', '--find-copies-harder']
1508 self.refchange_showlog = False
1510 self.COMPUTED_KEYS = [
1524 def get_repo_shortname(self):
1525 """Use the last part of the repo path, with ".git" stripped off if present."""
1527 basename = os.path.basename(os.path.abspath(self.get_repo_path()))
1528 m = self.REPO_NAME_RE.match(basename)
1530 return m.group('name')
1534 def get_pusher(self):
1535 raise NotImplementedError()
1537 def get_pusher_email(self):
1540 def get_administrator(self):
1541 return 'the administrator of this repository'
1543 def get_emailprefix(self):
1546 def get_repo_path(self):
1547 if read_git_output(['rev-parse', '--is-bare-repository']) == 'true':
1548 path = get_git_dir()
1550 path = read_git_output(['rev-parse', '--show-toplevel'])
1551 return os.path.abspath(path)
1553 def get_charset(self):
1556 def get_values(self):
1557 """Return a dictionary {keyword : expansion} for this Environment.
1559 This method is called by Change._compute_values(). The keys
1560 in the returned dictionary are available to be used in any of
1561 the templates. The dictionary is created by calling
1562 self.get_NAME() for each of the attributes named in
1563 COMPUTED_KEYS and recording those that do not return None.
1564 The return value is always a new dictionary."""
1566 if self._values is None:
1569 for key in self.COMPUTED_KEYS:
1570 value = getattr(self, 'get_%s' % (key,))()
1571 if value is not None:
1574 self._values = values
1576 return self._values.copy()
1578 def get_refchange_recipients(self, refchange):
1579 """Return the recipients for notifications about refchange.
1581 Return the list of email addresses to which notifications
1582 about the specified ReferenceChange should be sent."""
1584 raise NotImplementedError()
1586 def get_announce_recipients(self, annotated_tag_change):
1587 """Return the recipients for notifications about annotated_tag_change.
1589 Return the list of email addresses to which notifications
1590 about the specified AnnotatedTagChange should be sent."""
1592 raise NotImplementedError()
1594 def get_reply_to_refchange(self, refchange):
1595 return self.get_pusher_email()
1597 def get_revision_recipients(self, revision):
1598 """Return the recipients for messages about revision.
1600 Return the list of email addresses to which notifications
1601 about the specified Revision should be sent. This method
1602 could be overridden, for example, to take into account the
1603 contents of the revision when deciding whom to notify about
1604 it. For example, there could be a scheme for users to express
1605 interest in particular files or subdirectories, and only
1606 receive notification emails for revisions that affecting those
1609 raise NotImplementedError()
1611 def get_reply_to_commit(self, revision):
1612 return revision.author
1614 def filter_body(self, lines):
1615 """Filter the lines intended for an email body.
1617 lines is an iterable over the lines that would go into the
1618 email body. Filter it (e.g., limit the number of lines, the
1619 line length, character set, etc.), returning another iterable.
1620 See FilterLinesEnvironmentMixin and MaxlinesEnvironmentMixin
1621 for classes implementing this functionality."""
1626 class ConfigEnvironmentMixin(Environment):
1627 """A mixin that sets self.config to its constructor's config argument.
1629 This class's constructor consumes the "config" argument.
1631 Mixins that need to inspect the config should inherit from this
1632 class (1) to make sure that "config" is still in the constructor
1633 arguments with its own constructor runs and/or (2) to be sure that
1634 self.config is set after construction."""
1636 def __init__(self, config, **kw):
1637 super(ConfigEnvironmentMixin, self).__init__(**kw)
1638 self.config = config
1641 class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):
1642 """An Environment that reads most of its information from "git config"."""
1644 def __init__(self, config, **kw):
1645 super(ConfigOptionsEnvironmentMixin, self).__init__(
1649 self.announce_show_shortlog = config.get_bool(
1650 'announceshortlog', default=self.announce_show_shortlog
1653 self.refchange_showlog = config.get_bool(
1654 'refchangeshowlog', default=self.refchange_showlog
1657 maxcommitemails = config.get('maxcommitemails')
1658 if maxcommitemails is not None:
1660 self.maxcommitemails = int(maxcommitemails)
1663 '*** Malformed value for multimailhook.maxCommitEmails: %s\n' % maxcommitemails
1664 + '*** Expected a number. Ignoring.\n'
1667 diffopts = config.get('diffopts')
1668 if diffopts is not None:
1669 self.diffopts = shlex.split(diffopts)
1671 logopts = config.get('logopts')
1672 if logopts is not None:
1673 self.logopts = shlex.split(logopts)
1675 reply_to = config.get('replyTo')
1676 self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to)
1678 self.__reply_to_refchange is not None
1679 and self.__reply_to_refchange.lower() == 'author'
1681 raise ConfigurationException(
1682 '"author" is not an allowed setting for replyToRefchange'
1684 self.__reply_to_commit = config.get('replyToCommit', default=reply_to)
1686 def get_administrator(self):
1688 self.config.get('administrator')
1689 or self.get_sender()
1690 or super(ConfigOptionsEnvironmentMixin, self).get_administrator()
1693 def get_repo_shortname(self):
1695 self.config.get('reponame')
1696 or super(ConfigOptionsEnvironmentMixin, self).get_repo_shortname()
1699 def get_emailprefix(self):
1700 emailprefix = self.config.get('emailprefix')
1701 if emailprefix and emailprefix.strip():
1702 return emailprefix.strip() + ' '
1704 return '[%s] ' % (self.get_repo_shortname(),)
1706 def get_sender(self):
1707 return self.config.get('envelopesender')
1709 def get_fromaddr(self):
1710 fromaddr = self.config.get('from')
1714 config = Config('user')
1715 fromname = config.get('name', default='')
1716 fromemail = config.get('email', default='')
1718 return formataddr([fromname, fromemail])
1720 return self.get_sender()
1722 def get_reply_to_refchange(self, refchange):
1723 if self.__reply_to_refchange is None:
1724 return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_refchange(refchange)
1725 elif self.__reply_to_refchange.lower() == 'pusher':
1726 return self.get_pusher_email()
1727 elif self.__reply_to_refchange.lower() == 'none':
1730 return self.__reply_to_refchange
1732 def get_reply_to_commit(self, revision):
1733 if self.__reply_to_commit is None:
1734 return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_commit(revision)
1735 elif self.__reply_to_commit.lower() == 'author':
1736 return revision.get_author()
1737 elif self.__reply_to_commit.lower() == 'pusher':
1738 return self.get_pusher_email()
1739 elif self.__reply_to_commit.lower() == 'none':
1742 return self.__reply_to_commit
1745 class FilterLinesEnvironmentMixin(Environment):
1746 """Handle encoding and maximum line length of body lines.
1748 emailmaxlinelength (int or None)
1750 The maximum length of any single line in the email body.
1751 Longer lines are truncated at that length with ' [...]'
1756 If this field is set to True, then the email body text is
1757 expected to be UTF-8. Any invalid characters are
1758 converted to U+FFFD, the Unicode replacement character
1759 (encoded as UTF-8, of course).
1763 def __init__(self, strict_utf8=True, emailmaxlinelength=500, **kw):
1764 super(FilterLinesEnvironmentMixin, self).__init__(**kw)
1765 self.__strict_utf8 = strict_utf8
1766 self.__emailmaxlinelength = emailmaxlinelength
1768 def filter_body(self, lines):
1769 lines = super(FilterLinesEnvironmentMixin, self).filter_body(lines)
1770 if self.__strict_utf8:
1771 lines = (line.decode(ENCODING, 'replace') for line in lines)
1772 # Limit the line length in Unicode-space to avoid
1773 # splitting characters:
1774 if self.__emailmaxlinelength:
1775 lines = limit_linelength(lines, self.__emailmaxlinelength)
1776 lines = (line.encode(ENCODING, 'replace') for line in lines)
1777 elif self.__emailmaxlinelength:
1778 lines = limit_linelength(lines, self.__emailmaxlinelength)
1783 class ConfigFilterLinesEnvironmentMixin(
1784 ConfigEnvironmentMixin,
1785 FilterLinesEnvironmentMixin,
1787 """Handle encoding and maximum line length based on config."""
1789 def __init__(self, config, **kw):
1790 strict_utf8 = config.get_bool('emailstrictutf8', default=None)
1791 if strict_utf8 is not None:
1792 kw['strict_utf8'] = strict_utf8
1794 emailmaxlinelength = config.get('emailmaxlinelength')
1795 if emailmaxlinelength is not None:
1796 kw['emailmaxlinelength'] = int(emailmaxlinelength)
1798 super(ConfigFilterLinesEnvironmentMixin, self).__init__(
1803 class MaxlinesEnvironmentMixin(Environment):
1804 """Limit the email body to a specified number of lines."""
1806 def __init__(self, emailmaxlines, **kw):
1807 super(MaxlinesEnvironmentMixin, self).__init__(**kw)
1808 self.__emailmaxlines = emailmaxlines
1810 def filter_body(self, lines):
1811 lines = super(MaxlinesEnvironmentMixin, self).filter_body(lines)
1812 if self.__emailmaxlines:
1813 lines = limit_lines(lines, self.__emailmaxlines)
1817 class ConfigMaxlinesEnvironmentMixin(
1818 ConfigEnvironmentMixin,
1819 MaxlinesEnvironmentMixin,
1821 """Limit the email body to the number of lines specified in config."""
1823 def __init__(self, config, **kw):
1824 emailmaxlines = int(config.get('emailmaxlines', default='0'))
1825 super(ConfigMaxlinesEnvironmentMixin, self).__init__(
1827 emailmaxlines=emailmaxlines,
1832 class PusherDomainEnvironmentMixin(ConfigEnvironmentMixin):
1833 """Deduce pusher_email from pusher by appending an emaildomain."""
1835 def __init__(self, **kw):
1836 super(PusherDomainEnvironmentMixin, self).__init__(**kw)
1837 self.__emaildomain = self.config.get('emaildomain')
1839 def get_pusher_email(self):
1840 if self.__emaildomain:
1841 # Derive the pusher's full email address in the default way:
1842 return '%s@%s' % (self.get_pusher(), self.__emaildomain)
1844 return super(PusherDomainEnvironmentMixin, self).get_pusher_email()
1847 class StaticRecipientsEnvironmentMixin(Environment):
1848 """Set recipients statically based on constructor parameters."""
1852 refchange_recipients, announce_recipients, revision_recipients,
1855 super(StaticRecipientsEnvironmentMixin, self).__init__(**kw)
1857 # The recipients for various types of notification emails, as
1858 # RFC 2822 email addresses separated by commas (or the empty
1859 # string if no recipients are configured). Although there is
1860 # a mechanism to choose the recipient lists based on on the
1861 # actual *contents* of the change being reported, we only
1862 # choose based on the *type* of the change. Therefore we can
1863 # compute them once and for all:
1864 self.__refchange_recipients = refchange_recipients
1865 self.__announce_recipients = announce_recipients
1866 self.__revision_recipients = revision_recipients
1868 def get_refchange_recipients(self, refchange):
1869 return self.__refchange_recipients
1871 def get_announce_recipients(self, annotated_tag_change):
1872 return self.__announce_recipients
1874 def get_revision_recipients(self, revision):
1875 return self.__revision_recipients
1878 class ConfigRecipientsEnvironmentMixin(
1879 ConfigEnvironmentMixin,
1880 StaticRecipientsEnvironmentMixin
1882 """Determine recipients statically based on config."""
1884 def __init__(self, config, **kw):
1885 super(ConfigRecipientsEnvironmentMixin, self).__init__(
1887 refchange_recipients=self._get_recipients(
1888 config, 'refchangelist', 'mailinglist',
1890 announce_recipients=self._get_recipients(
1891 config, 'announcelist', 'refchangelist', 'mailinglist',
1893 revision_recipients=self._get_recipients(
1894 config, 'commitlist', 'mailinglist',
1899 def _get_recipients(self, config, *names):
1900 """Return the recipients for a particular type of message.
1902 Return the list of email addresses to which a particular type
1903 of notification email should be sent, by looking at the config
1904 value for "multimailhook.$name" for each of names. Use the
1905 value from the first name that is configured. The return
1906 value is a (possibly empty) string containing RFC 2822 email
1907 addresses separated by commas. If no configuration could be
1908 found, raise a ConfigurationException."""
1911 retval = config.get_recipients(name)
1912 if retval is not None:
1915 hint = 'Please set "%s.%s"' % (config.section, name)
1918 'Please set one of the following:\n "%s"'
1919 % ('"\n "'.join('%s.%s' % (config.section, name) for name in names))
1922 raise ConfigurationException(
1923 'The list of recipients for %s is not configured.\n%s' % (names[0], hint)
1927 class ProjectdescEnvironmentMixin(Environment):
1928 """Make a "projectdesc" value available for templates.
1930 By default, it is set to the first line of $GIT_DIR/description
1931 (if that file is present and appears to be set meaningfully)."""
1933 def __init__(self, **kw):
1934 super(ProjectdescEnvironmentMixin, self).__init__(**kw)
1935 self.COMPUTED_KEYS += ['projectdesc']
1937 def get_projectdesc(self):
1938 """Return a one-line descripition of the project."""
1940 git_dir = get_git_dir()
1942 projectdesc = open(os.path.join(git_dir, 'description')).readline().strip()
1943 if projectdesc and not projectdesc.startswith('Unnamed repository'):
1948 return 'UNNAMED PROJECT'
1951 class GenericEnvironmentMixin(Environment):
1952 def get_pusher(self):
1953 return self.osenv.get('USER', 'unknown user')
1956 class GenericEnvironment(
1957 ProjectdescEnvironmentMixin,
1958 ConfigMaxlinesEnvironmentMixin,
1959 ConfigFilterLinesEnvironmentMixin,
1960 ConfigRecipientsEnvironmentMixin,
1961 PusherDomainEnvironmentMixin,
1962 ConfigOptionsEnvironmentMixin,
1963 GenericEnvironmentMixin,
1969 class GitoliteEnvironmentMixin(Environment):
1970 def get_repo_shortname(self):
1971 # The gitolite environment variable $GL_REPO is a pretty good
1972 # repo_shortname (though it's probably not as good as a value
1973 # the user might have explicitly put in his config).
1975 self.osenv.get('GL_REPO', None)
1976 or super(GitoliteEnvironmentMixin, self).get_repo_shortname()
1979 def get_pusher(self):
1980 return self.osenv.get('GL_USER', 'unknown user')
1983 class GitoliteEnvironment(
1984 ProjectdescEnvironmentMixin,
1985 ConfigMaxlinesEnvironmentMixin,
1986 ConfigFilterLinesEnvironmentMixin,
1987 ConfigRecipientsEnvironmentMixin,
1988 PusherDomainEnvironmentMixin,
1989 ConfigOptionsEnvironmentMixin,
1990 GitoliteEnvironmentMixin,
1997 """Represent an entire push (i.e., a group of ReferenceChanges).
1999 It is easy to figure out what commits were added to a *branch* by
2002 git rev-list change.old..change.new
2004 or removed from a *branch*:
2006 git rev-list change.new..change.old
2008 But it is not quite so trivial to determine which entirely new
2009 commits were added to the *repository* by a push and which old
2010 commits were discarded by a push. A big part of the job of this
2011 class is to figure out these things, and to make sure that new
2012 commits are only detailed once even if they were added to multiple
2015 The first step is to determine the "other" references--those
2016 unaffected by the current push. They are computed by
2017 Push._compute_other_ref_sha1s() by listing all references then
2018 removing any affected by this push.
2020 The commits contained in the repository before this push were
2022 git rev-list other1 other2 other3 ... change1.old change2.old ...
2024 Where "changeN.old" is the old value of one of the references
2025 affected by this push.
2027 The commits contained in the repository after this push are
2029 git rev-list other1 other2 other3 ... change1.new change2.new ...
2031 The commits added by this push are the difference between these
2032 two sets, which can be written
2035 ^other1 ^other2 ... \
2036 ^change1.old ^change2.old ... \
2037 change1.new change2.new ...
2039 The commits removed by this push can be computed by
2042 ^other1 ^other2 ... \
2043 ^change1.new ^change2.new ... \
2044 change1.old change2.old ...
2046 The last point is that it is possible that other pushes are
2047 occurring simultaneously to this one, so reference values can
2048 change at any time. It is impossible to eliminate all race
2049 conditions, but we reduce the window of time during which problems
2050 can occur by translating reference names to SHA1s as soon as
2051 possible and working with SHA1s thereafter (because SHA1s are
2054 # A map {(changeclass, changetype) : integer} specifying the order
2055 # that reference changes will be processed if multiple reference
2056 # changes are included in a single push. The order is significant
2057 # mostly because new commit notifications are threaded together
2058 # with the first reference change that includes the commit. The
2059 # following order thus causes commits to be grouped with branch
2060 # changes (as opposed to tag changes) if possible.
2062 (value, i) for (i, value) in enumerate([
2063 (BranchChange, 'update'),
2064 (BranchChange, 'create'),
2065 (AnnotatedTagChange, 'update'),
2066 (AnnotatedTagChange, 'create'),
2067 (NonAnnotatedTagChange, 'update'),
2068 (NonAnnotatedTagChange, 'create'),
2069 (BranchChange, 'delete'),
2070 (AnnotatedTagChange, 'delete'),
2071 (NonAnnotatedTagChange, 'delete'),
2072 (OtherReferenceChange, 'update'),
2073 (OtherReferenceChange, 'create'),
2074 (OtherReferenceChange, 'delete'),
2078 def __init__(self, changes):
2079 self.changes = sorted(changes, key=self._sort_key)
2081 # The SHA-1s of commits referred to by references unaffected
2083 other_ref_sha1s = self._compute_other_ref_sha1s()
2085 self._old_rev_exclusion_spec = self._compute_rev_exclusion_spec(
2086 other_ref_sha1s.union(
2088 for change in self.changes
2089 if change.old.type in ['commit', 'tag']
2092 self._new_rev_exclusion_spec = self._compute_rev_exclusion_spec(
2093 other_ref_sha1s.union(
2095 for change in self.changes
2096 if change.new.type in ['commit', 'tag']
2101 def _sort_key(klass, change):
2102 return (klass.SORT_ORDER[change.__class__, change.change_type], change.refname,)
2104 def _compute_other_ref_sha1s(self):
2105 """Return the GitObjects referred to by references unaffected by this push."""
2107 # The refnames being changed by this push:
2110 for change in self.changes
2113 # The SHA-1s of commits referred to by all references in this
2114 # repository *except* updated_refs:
2117 '%(objectname) %(objecttype) %(refname)\n'
2118 '%(*objectname) %(*objecttype) %(refname)'
2120 for line in read_git_lines(['for-each-ref', '--format=%s' % (fmt,)]):
2121 (sha1, type, name) = line.split(' ', 2)
2122 if sha1 and type == 'commit' and name not in updated_refs:
2127 def _compute_rev_exclusion_spec(self, sha1s):
2128 """Return an exclusion specification for 'git rev-list'.
2130 git_objects is an iterable over GitObject instances. Return a
2131 string that can be passed to the standard input of 'git
2132 rev-list --stdin' to exclude all of the commits referred to by
2136 ['^%s\n' % (sha1,) for sha1 in sorted(sha1s)]
2139 def get_new_commits(self, reference_change=None):
2140 """Return a list of commits added by this push.
2142 Return a list of the object names of commits that were added
2143 by the part of this push represented by reference_change. If
2144 reference_change is None, then return a list of *all* commits
2145 added by this push."""
2147 if not reference_change:
2150 for change in self.changes
2153 elif not reference_change.new.commit_sha1:
2156 new_revs = [reference_change.new.commit_sha1]
2158 cmd = ['rev-list', '--stdin'] + new_revs
2159 return read_git_lines(cmd, input=self._old_rev_exclusion_spec)
2161 def get_discarded_commits(self, reference_change):
2162 """Return a list of commits discarded by this push.
2164 Return a list of the object names of commits that were
2165 entirely discarded from the repository by the part of this
2166 push represented by reference_change."""
2168 if not reference_change.old.commit_sha1:
2171 old_revs = [reference_change.old.commit_sha1]
2173 cmd = ['rev-list', '--stdin'] + old_revs
2174 return read_git_lines(cmd, input=self._new_rev_exclusion_spec)
2176 def send_emails(self, mailer, body_filter=None):
2177 """Use send all of the notification emails needed for this push.
2179 Use send all of the notification emails (including reference
2180 change emails and commit emails) needed for this push. Send
2181 the emails using mailer. If body_filter is not None, then use
2182 it to filter the lines that are intended for the email
2185 # The sha1s of commits that were introduced by this push.
2186 # They will be removed from this set as they are processed, to
2187 # guarantee that one (and only one) email is generated for
2189 unhandled_sha1s = set(self.get_new_commits())
2190 for change in self.changes:
2191 # Check if we've got anyone to send to
2192 if not change.recipients:
2194 '*** no recipients configured so no email will be sent\n'
2195 '*** for %r update %s->%s\n'
2196 % (change.refname, change.old.sha1, change.new.sha1,)
2199 sys.stderr.write('Sending notification emails to: %s\n' % (change.recipients,))
2200 mailer.send(change.generate_email(self, body_filter), change.recipients)
2203 for sha1 in reversed(list(self.get_new_commits(change))):
2204 if sha1 in unhandled_sha1s:
2206 unhandled_sha1s.remove(sha1)
2208 max_emails = change.environment.maxcommitemails
2209 if max_emails and len(sha1s) > max_emails:
2211 '*** Too many new commits (%d), not sending commit emails.\n' % len(sha1s)
2212 + '*** Try setting multimailhook.maxCommitEmails to a greater value\n'
2213 + '*** Currently, multimailhook.maxCommitEmails=%d\n' % max_emails
2217 for (num, sha1) in enumerate(sha1s):
2218 rev = Revision(change, GitObject(sha1), num=num+1, tot=len(sha1s))
2220 mailer.send(rev.generate_email(self, body_filter), rev.recipients)
2222 # Consistency check:
2225 'ERROR: No emails were sent for the following new commits:\n'
2227 % ('\n '.join(sorted(unhandled_sha1s)),)
2231 def run_as_post_receive_hook(environment, mailer):
2233 for line in sys.stdin:
2234 (oldrev, newrev, refname) = line.strip().split(' ', 2)
2236 ReferenceChange.create(environment, oldrev, newrev, refname)
2238 push = Push(changes)
2239 push.send_emails(mailer, body_filter=environment.filter_body)
2242 def run_as_update_hook(environment, mailer, refname, oldrev, newrev):
2244 ReferenceChange.create(
2246 read_git_output(['rev-parse', '--verify', oldrev]),
2247 read_git_output(['rev-parse', '--verify', newrev]),
2251 push = Push(changes)
2252 push.send_emails(mailer, body_filter=environment.filter_body)
2255 def choose_mailer(config, environment):
2256 mailer = config.get('mailer', default='sendmail')
2258 if mailer == 'smtp':
2259 smtpserver = config.get('smtpserver', default='localhost')
2260 mailer = SMTPMailer(
2261 envelopesender=(environment.get_sender() or environment.get_fromaddr()),
2262 smtpserver=smtpserver,
2264 elif mailer == 'sendmail':
2265 command = config.get('sendmailcommand')
2267 command = shlex.split(command)
2268 mailer = SendMailer(command=command, envelopesender=environment.get_sender())
2271 'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n' % mailer
2272 + 'please use one of "smtp" or "sendmail".\n'
2278 KNOWN_ENVIRONMENTS = {
2279 'generic' : GenericEnvironmentMixin,
2280 'gitolite' : GitoliteEnvironmentMixin,
2284 def choose_environment(config, osenv=None, env=None, recipients=None):
2288 environment_mixins = [
2289 ProjectdescEnvironmentMixin,
2290 ConfigMaxlinesEnvironmentMixin,
2291 ConfigFilterLinesEnvironmentMixin,
2292 PusherDomainEnvironmentMixin,
2293 ConfigOptionsEnvironmentMixin,
2301 env = config.get('environment')
2304 if 'GL_USER' in osenv and 'GL_REPO' in osenv:
2309 environment_mixins.append(KNOWN_ENVIRONMENTS[env])
2312 environment_mixins.insert(0, StaticRecipientsEnvironmentMixin)
2313 environment_kw['refchange_recipients'] = recipients
2314 environment_kw['announce_recipients'] = recipients
2315 environment_kw['revision_recipients'] = recipients
2317 environment_mixins.insert(0, ConfigRecipientsEnvironmentMixin)
2319 environment_klass = type(
2320 'EffectiveEnvironment',
2321 tuple(environment_mixins) + (Environment,),
2324 return environment_klass(**environment_kw)
2328 parser = optparse.OptionParser(
2329 description=__doc__,
2330 usage='%prog [OPTIONS]\n or: %prog [OPTIONS] REFNAME OLDREV NEWREV',
2334 '--environment', '--env', action='store', type='choice',
2335 choices=['generic', 'gitolite'], default=None,
2337 'Choose type of environment is in use. Default is taken from '
2338 'multimailhook.environment if set; otherwise "generic".'
2342 '--stdout', action='store_true', default=False,
2343 help='Output emails to stdout rather than sending them.',
2346 '--recipients', action='store', default=None,
2347 help='Set list of email recipients for all types of emails.',
2350 '--show-env', action='store_true', default=False,
2352 'Write to stderr the values determined for the environment '
2353 '(intended for debugging purposes).'
2357 (options, args) = parser.parse_args(args)
2359 config = Config('multimailhook')
2362 environment = choose_environment(
2363 config, osenv=os.environ,
2364 env=options.environment,
2365 recipients=options.recipients,
2368 if options.show_env:
2369 sys.stderr.write('Environment values:\n')
2370 for (k,v) in sorted(environment.get_values().items()):
2371 sys.stderr.write(' %s : %r\n' % (k,v))
2372 sys.stderr.write('\n')
2375 mailer = OutputMailer(sys.stdout)
2377 mailer = choose_mailer(config, environment)
2379 # Dual mode: if arguments were specified on the command line, run
2380 # like an update hook; otherwise, run as a post-receive hook.
2383 parser.error('Need zero or three non-option arguments')
2384 (refname, oldrev, newrev) = args
2385 run_as_update_hook(environment, mailer, refname, oldrev, newrev)
2387 run_as_post_receive_hook(environment, mailer)
2388 except ConfigurationException, e:
2392 if __name__ == '__main__':