git-multimail: an improved replacement for post-receive-email
[git.git] / contrib / hooks / multimail / git_multimail.py
blob81c6a5170615c083eb44762e6ed143c503701fd4
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
42 using sendmail.
44 See the accompanying README file for the complete documentation.
46 """
48 import sys
49 import os
50 import re
51 import bisect
52 import subprocess
53 import shlex
54 import optparse
55 import smtplib
57 try:
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
62 except ImportError:
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
70 DEBUG = False
72 ZEROS = '0' * 40
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 = """\
98 To: %(recipients)s
99 Subject: %(subject)s
100 MIME-Version: 1.0
101 Content-Type: text/plain; charset=%(charset)s
102 Content-Transfer-Encoding: 8bit
103 Message-ID: %(msgid)s
104 From: %(fromaddr)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 = """\
125 -- \n\
126 To stop receiving notification emails like this one, please contact
127 %(administrator)s.
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
177 from the repository.
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 = """\
224 To: %(recipients)s
225 Subject: %(emailprefix)s%(num)02d/%(tot)02d: %(oneline)s
226 MIME-Version: 1.0
227 Content-Type: text/plain; charset=%(charset)s
228 Content-Transfer-Encoding: 8bit
229 From: %(fromaddr)s
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
236 X-Git-Rev: %(rev)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):
254 self.cmd = cmd
255 self.retcode = retcode
256 Exception.__init__(
257 self,
258 'Command "%s" failed with retcode %s' % (' '.join(cmd), retcode,)
262 class ConfigurationException(Exception):
263 pass
266 def read_git_output(args, input=None, keepends=False, **kw):
267 """Read the output of a Git command."""
269 return read_output(
270 ['git', '-c', 'i18n.logoutputencoding=%s' % (ENCODING,)] + args,
271 input=input, keepends=keepends, **kw
275 def read_output(cmd, input=None, keepends=False, **kw):
276 if input:
277 stdin = subprocess.PIPE
278 else:
279 stdin = None
280 p = subprocess.Popen(
281 cmd, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw
283 (out, err) = p.communicate(input)
284 retcode = p.wait()
285 if retcode:
286 raise CommandError(cmd, retcode)
287 if not keepends:
288 out = out.rstrip('\n\r')
289 return out
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
307 config paths."""
309 self.section = section
310 if git_config:
311 self.env = os.environ.copy()
312 self.env['GIT_CONFIG'] = git_config
313 else:
314 self.env = None
316 @staticmethod
317 def _split(s):
318 """Split NUL-terminated values."""
320 words = s.split('\0')
321 assert words[-1] == ''
322 return words[:-1]
324 def get(self, name, default=None):
325 try:
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
331 return values[0]
332 except CommandError:
333 return default
335 def get_bool(self, name, default=None):
336 try:
337 value = read_git_output(
338 ['config', '--get', '--bool', '%s.%s' % (self.section, name)],
339 env=self.env,
341 except CommandError:
342 return default
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
349 is unset."""
351 try:
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:
357 if e.retcode == 1:
358 # "the section or key is invalid"; i.e., there is no
359 # value for the specified key.
360 return default
361 else:
362 raise
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)
372 if lines is None:
373 return default
374 return ', '.join(line.strip() for line in lines)
376 def set(self, name, value):
377 read_git_output(
378 ['config', '%s.%s' % (self.section, name), value],
379 env=self.env,
382 def add(self, name, value):
383 read_git_output(
384 ['config', '--add', '%s.%s' % (self.section, name), value],
385 env=self.env,
388 def has_key(self, name):
389 return self.get_all(name, default=None) is not None
391 def unset_all(self, name):
392 try:
393 read_git_output(
394 ['config', '--unset-all', '%s.%s' % (self.section, name)],
395 env=self.env,
397 except CommandError, e:
398 if e.retcode == 5:
399 # The name doesn't exist, which is what we wanted anyway...
400 pass
401 else:
402 raise
404 def set_recipients(self, name, value):
405 self.unset_all(name)
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)."""
418 cmd = [
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:
428 yield line
430 if index >= max_lines:
431 yield '... %d lines suppressed ...\n' % (index + 1 - max_lines,)
434 def limit_linelength(lines, max_linelength):
435 for line in lines:
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'
439 yield line
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)
452 def __len__(self):
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):
464 if sha1 == ZEROS:
465 self.sha1 = self.type = self.commit_sha1 = None
466 else:
467 self.sha1 = sha1
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':
473 try:
474 self.commit_sha1 = read_git_output(
475 ['rev-parse', '--verify', '%s^0' % (self.sha1,)]
477 except CommandError:
478 # Cannot deref tag to determine commit_sha1
479 self.commit_sha1 = None
480 else:
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."""
488 if not self.sha1:
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
496 def __hash__(self):
497 return hash(self.sha1)
499 def __nonzero__(self):
500 return bool(self.sha1)
502 def __str__(self):
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
515 self._values = None
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
523 dictionary."""
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
534 a new dictionary."""
536 if self._values is None:
537 self._values = self._compute_values()
539 values = self._values.copy()
540 if extra_values:
541 values.update(extra_values)
542 return values
544 def expand(self, template, **extra_values):
545 """Expand template.
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):
559 yield line % values
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)
571 try:
572 value = value % values
573 except KeyError, e:
574 if DEBUG:
575 sys.stderr.write(
576 'Warning: unknown variable %r in the following line; line skipped:\n'
577 ' %s\n'
578 % (e.args[0], line,)
580 else:
581 try:
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):
586 yield splitline
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
625 email body."""
627 for line in self.generate_email_header():
628 yield line
629 yield '\n'
630 for line in self.generate_email_intro():
631 yield line
633 body = self.generate_email_body(push)
634 if body_filter is not None:
635 body = body_filter(body)
636 for line in body:
637 yield line
639 for line in self.generate_email_footer():
640 yield line
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
649 self.rev = rev
650 self.change_type = self.reference_change.change_type
651 self.refname = self.reference_change.refname
652 self.num = num
653 self.tot = tot
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)
678 if reply_to:
679 values['reply_to'] = reply_to
681 return values
683 def generate_email_header(self):
684 for line in self.expand_header_lines(REVISION_HEADER_TEMPLATE):
685 yield line
687 def generate_email_intro(self):
688 for line in self.expand_lines(REVISION_INTRO_TEMPLATE):
689 yield line
691 def generate_email_body(self, push):
692 """Show this revision."""
694 return read_git_lines(
696 'log', '-C',
697 '--stat', '-p', '--cc',
698 '-1', self.rev.sha1,
700 keepends=True,
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
718 to instantiate."""
720 REF_RE = re.compile(r'^refs\/(?P<area>[^\/]+)\/(?P<shortname>.*)$')
722 @staticmethod
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)
731 rev = new or old
733 # The revision type tells us what type the commit is, combined with
734 # the location of the ref we can decide between
735 # - working branch
736 # - tracking branch
737 # - unannotated tag
738 # - annotated tag
739 m = ReferenceChange.REF_RE.match(refname)
740 if m:
741 area = m.group('area')
742 short_refname = m.group('shortname')
743 else:
744 area = ''
745 short_refname = refname
747 if rev.type == 'tag':
748 # Annotated tag:
749 klass = AnnotatedTagChange
750 elif rev.type == 'commit':
751 if area == 'tags':
752 # Non-annotated tag:
753 klass = NonAnnotatedTagChange
754 elif area == 'heads':
755 # Branch:
756 klass = BranchChange
757 elif area == 'remotes':
758 # Tracking branch:
759 sys.stderr.write(
760 '*** Push-update of tracking branch %r\n'
761 '*** - incomplete email generated.\n'
762 % (refname,)
764 klass = OtherReferenceChange
765 else:
766 # Some other reference namespace:
767 sys.stderr.write(
768 '*** Push-update of strange reference %r\n'
769 '*** - incomplete email generated.\n'
770 % (refname,)
772 klass = OtherReferenceChange
773 else:
774 # Anything else (is there anything else?)
775 sys.stderr.write(
776 '*** Unknown type of update to %r (%s)\n'
777 '*** - incomplete email generated.\n'
778 % (refname, rev.type,)
780 klass = OtherReferenceChange
782 return klass(
783 environment,
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)
790 self.change_type = {
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
797 self.old = old
798 self.new = new
799 self.rev = rev
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
819 if self.old:
820 values['oldrev_type'] = self.old.type
821 if self.new:
822 values['newrev_type'] = self.new.type
824 reply_to = self.environment.get_reply_to_refchange(self)
825 if reply_to:
826 values['reply_to'] = reply_to
828 return values
830 def get_subject(self):
831 template = {
832 'create' : REF_CREATED_SUBJECT_TEMPLATE,
833 'update' : REF_UPDATED_SUBJECT_TEMPLATE,
834 'delete' : REF_DELETED_SUBJECT_TEMPLATE,
835 }[self.change_type]
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(),
842 yield line
844 def generate_email_intro(self):
845 for line in self.expand_lines(REFCHANGE_INTRO_TEMPLATE):
846 yield line
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()."""
854 change_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:
860 yield line
862 for line in self.generate_revision_change_summary(push):
863 yield line
865 def generate_email_footer(self):
866 return self.expand_lines(FOOTER_TEMPLATE)
868 def generate_revision_change_log(self, new_commits_list):
869 if self.showlog:
870 yield '\n'
871 yield 'Detailed log of new commits:\n\n'
872 for line in read_git_lines(
873 ['log', '--no-walk']
874 + self.logopts
875 + new_commits_list
876 + ['--'],
877 keepends=True,
879 yield line
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
888 # change).
889 sha1s = list(push.get_new_commits(self))
890 sha1s.reverse()
891 tot = len(sha1s)
892 new_revisions = [
893 Revision(self, GitObject(sha1), num=i+1, tot=tot)
894 for (i, sha1) in enumerate(sha1s)
897 if new_revisions:
898 yield self.expand('This %(refname_type)s includes the following new commits:\n')
899 yield '\n'
900 for r in new_revisions:
901 (sha1, subject) = r.rev.get_summary()
902 yield r.expand(
903 BRIEF_SUMMARY_TEMPLATE, action='new', text=subject,
905 yield '\n'
906 for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot):
907 yield line
908 for line in self.generate_revision_change_log([r.rev.sha1 for r in new_revisions]):
909 yield line
910 else:
911 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
912 yield line
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,)
937 if adds:
938 new_commits_list = push.get_new_commits(self)
939 else:
940 new_commits_list = []
941 new_commits = CommitSet(new_commits_list)
943 if discards:
944 discarded_commits = CommitSet(push.get_discarded_commits(self))
945 else:
946 discarded_commits = CommitSet([])
948 if discards and adds:
949 for (sha1, subject) in discards:
950 if sha1 in discarded_commits:
951 action = 'discards'
952 else:
953 action = 'omits'
954 yield self.expand(
955 BRIEF_SUMMARY_TEMPLATE, action=action,
956 rev_short=sha1, text=subject,
958 for (sha1, subject) in adds:
959 if sha1 in new_commits:
960 action = 'new'
961 else:
962 action = 'adds'
963 yield self.expand(
964 BRIEF_SUMMARY_TEMPLATE, action=action,
965 rev_short=sha1, text=subject,
967 yield '\n'
968 for line in self.expand_lines(NON_FF_TEMPLATE):
969 yield line
971 elif discards:
972 for (sha1, subject) in discards:
973 if sha1 in discarded_commits:
974 action = 'discards'
975 else:
976 action = 'omits'
977 yield self.expand(
978 BRIEF_SUMMARY_TEMPLATE, action=action,
979 rev_short=sha1, text=subject,
981 yield '\n'
982 for line in self.expand_lines(REWIND_ONLY_TEMPLATE):
983 yield line
985 elif adds:
986 (sha1, subject) = self.old.get_summary()
987 yield self.expand(
988 BRIEF_SUMMARY_TEMPLATE, action='from',
989 rev_short=sha1, text=subject,
991 for (sha1, subject) in adds:
992 if sha1 in new_commits:
993 action = 'new'
994 else:
995 action = 'adds'
996 yield self.expand(
997 BRIEF_SUMMARY_TEMPLATE, action=action,
998 rev_short=sha1, text=subject,
1001 yield '\n'
1003 if new_commits:
1004 for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=len(new_commits)):
1005 yield line
1006 for line in self.generate_revision_change_log(new_commits_list):
1007 yield line
1008 else:
1009 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
1010 yield line
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.
1019 yield '\n'
1020 yield 'Summary of changes:\n'
1021 for line in read_git_lines(
1022 ['diff-tree']
1023 + self.diffopts
1024 + ['%s..%s' % (self.old.commit_sha1, self.new.commit_sha1,)],
1025 keepends=True,
1027 yield line
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))
1034 tot = len(sha1s)
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):
1042 yield line
1043 yield '\n'
1044 for r in discarded_revisions:
1045 (sha1, subject) = r.rev.get_summary()
1046 yield r.expand(
1047 BRIEF_SUMMARY_TEMPLATE, action='discards', text=subject,
1049 else:
1050 for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE):
1051 yield line
1053 elif not self.old.commit_sha1 and not self.new.commit_sha1:
1054 for line in self.expand_lines(NON_COMMIT_UPDATE_TEMPLATE):
1055 yield line
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()
1062 yield self.expand(
1063 BRIEF_SUMMARY_TEMPLATE, action='at',
1064 rev_short=sha1, text=subject,
1066 yield '\n'
1068 def generate_update_summary(self, push):
1069 """Called for the change of a pre-existing branch."""
1071 return iter([])
1073 def generate_delete_summary(self, push):
1074 """Called for the deletion of any type of reference."""
1076 (sha1, subject) = self.old.get_summary()
1077 yield self.expand(
1078 BRIEF_SUMMARY_TEMPLATE, action='was',
1079 rev_short=sha1, text=subject,
1081 yield '\n'
1084 class BranchChange(ReferenceChange):
1085 refname_type = 'branch'
1087 def __init__(self, environment, refname, short_refname, old, new, rev):
1088 ReferenceChange.__init__(
1089 self, environment,
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__(
1101 self, environment,
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 = (
1109 '%(*objectname)\n'
1110 '%(*objecttype)\n'
1111 '%(taggername)\n'
1112 '%(taggerdate)'
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
1119 # the tag
1120 [tagobject, tagtype, tagger, tagged] = read_git_lines(
1121 ['for-each-ref', '--format=%s' % (self.ANNOTATED_TAG_FORMAT,), self.refname],
1124 yield self.expand(
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
1131 # replacing
1132 try:
1133 prevtag = read_git_output(['describe', '--abbrev=0', '%s^' % (self.new,)])
1134 except CommandError:
1135 prevtag = None
1136 if prevtag:
1137 yield ' replaces %s\n' % (prevtag,)
1138 else:
1139 prevtag = None
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,)
1144 yield '\n'
1146 # Show the content of the tag message; this might contain a
1147 # change log or release notes so is worth displaying.
1148 yield LOGBEGIN
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:
1154 yield line
1156 if self.show_shortlog and tagtype == 'commit':
1157 # Only commit tags make sense to have rev-list operations
1158 # performed on them
1159 yield '\n'
1160 if prevtag:
1161 # Show changes since the previous release
1162 revlist = read_git_output(
1163 ['rev-list', '--pretty=short', '%s..%s' % (prevtag, self.new,)],
1164 keepends=True,
1166 else:
1167 # No previous tag, show all the changes since time
1168 # began
1169 revlist = read_git_output(
1170 ['rev-list', '--pretty=short', '%s' % (self.new,)],
1171 keepends=True,
1173 for line in read_git_lines(['shortlog'], input=revlist, keepends=True):
1174 yield line
1176 yield LOGEND
1177 yield '\n'
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):
1183 yield line
1185 for line in self.describe_tag(push):
1186 yield line
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):
1194 yield line
1196 for line in self.describe_tag(push):
1197 yield line
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):
1203 yield line
1205 yield self.expand(' tag was %(oldrev_short)s\n')
1206 yield '\n'
1209 class NonAnnotatedTagChange(ReferenceChange):
1210 refname_type = 'tag'
1212 def __init__(self, environment, refname, short_refname, old, new, rev):
1213 ReferenceChange.__init__(
1214 self, environment,
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):
1224 yield line
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):
1230 yield line
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):
1236 yield line
1238 for line in ReferenceChange.generate_delete_summary(self, push):
1239 yield line
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__(
1250 self, environment,
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
1268 addresses.
1272 raise NotImplementedError()
1275 class SendMailer(Mailer):
1276 """Send emails using 'sendmail -t'."""
1278 SENDMAIL_CANDIDATES = [
1279 '/usr/sbin/sendmail',
1280 '/usr/lib/sendmail',
1283 @staticmethod
1284 def find_sendmail():
1285 for path in SendMailer.SENDMAIL_CANDIDATES:
1286 if os.access(path, os.X_OK):
1287 return path
1288 else:
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
1300 envelopesender'."""
1302 if command:
1303 self.command = command[:]
1304 else:
1305 self.command = [self.find_sendmail(), '-t']
1307 if envelopesender:
1308 self.command.extend(['-f', envelopesender])
1310 def send(self, lines, to_addrs):
1311 try:
1312 p = subprocess.Popen(self.command, stdin=subprocess.PIPE)
1313 except OSError, e:
1314 sys.stderr.write(
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'
1320 sys.exit(1)
1321 try:
1322 p.stdin.writelines(lines)
1323 except:
1324 sys.stderr.write(
1325 '*** Error while generating commit email\n'
1326 '*** - mail sending aborted.\n'
1328 p.terminate()
1329 raise
1330 else:
1331 p.stdin.close()
1332 retcode = p.wait()
1333 if retcode:
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:
1342 sys.stderr.write(
1343 'fatal: git_multimail: cannot use SMTPMailer without a sender address.\n'
1344 'please set either multimailhook.envelopeSender or user.email\n'
1346 sys.exit(1)
1347 self.envelopesender = envelopesender
1348 self.smtpserver = smtpserver
1349 try:
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))
1354 sys.exit(1)
1356 def __del__(self):
1357 self.smtp.quit()
1359 def send(self, lines, to_addrs):
1360 try:
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))
1369 self.smtp.quit()
1370 sys.exit(1)
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):
1381 self.f = 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)
1389 def get_git_dir():
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."""
1395 try:
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')
1399 sys.exit(1)
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
1419 purposes.
1421 get_repo_path()
1423 Return the absolute path to the Git repository.
1425 get_emailprefix()
1427 Return a string that will be prefixed to every email's
1428 subject.
1430 get_pusher()
1432 Return the username of the person who pushed the changes.
1433 This value is used in the email body to indicate who
1434 pushed the change.
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).
1447 get_sender()
1449 Return the address to be used as the 'From' email address
1450 in the email envelope.
1452 get_fromaddr()
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>'.)
1458 get_administrator()
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
1474 emails.
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']
1507 self.logopts = []
1508 self.refchange_showlog = False
1510 self.COMPUTED_KEYS = [
1511 'administrator',
1512 'charset',
1513 'emailprefix',
1514 'fromaddr',
1515 'pusher',
1516 'pusher_email',
1517 'repo_path',
1518 'repo_shortname',
1519 'sender',
1522 self._values = None
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)
1529 if m:
1530 return m.group('name')
1531 else:
1532 return basename
1534 def get_pusher(self):
1535 raise NotImplementedError()
1537 def get_pusher_email(self):
1538 return None
1540 def get_administrator(self):
1541 return 'the administrator of this repository'
1543 def get_emailprefix(self):
1544 return ''
1546 def get_repo_path(self):
1547 if read_git_output(['rev-parse', '--is-bare-repository']) == 'true':
1548 path = get_git_dir()
1549 else:
1550 path = read_git_output(['rev-parse', '--show-toplevel'])
1551 return os.path.abspath(path)
1553 def get_charset(self):
1554 return CHARSET
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:
1567 values = {}
1569 for key in self.COMPUTED_KEYS:
1570 value = getattr(self, 'get_%s' % (key,))()
1571 if value is not None:
1572 values[key] = value
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
1607 files."""
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."""
1623 return lines
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__(
1646 config=config, **kw
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:
1659 try:
1660 self.maxcommitemails = int(maxcommitemails)
1661 except ValueError:
1662 sys.stderr.write(
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)
1677 if (
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):
1687 return (
1688 self.config.get('administrator')
1689 or self.get_sender()
1690 or super(ConfigOptionsEnvironmentMixin, self).get_administrator()
1693 def get_repo_shortname(self):
1694 return (
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() + ' '
1703 else:
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')
1711 if fromaddr:
1712 return fromaddr
1713 else:
1714 config = Config('user')
1715 fromname = config.get('name', default='')
1716 fromemail = config.get('email', default='')
1717 if fromemail:
1718 return formataddr([fromname, fromemail])
1719 else:
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':
1728 return None
1729 else:
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':
1740 return None
1741 else:
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 ' [...]'
1752 appended.
1754 strict_utf8 (bool)
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)
1780 return lines
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__(
1799 config=config, **kw
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)
1814 return lines
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__(
1826 config=config,
1827 emailmaxlines=emailmaxlines,
1828 **kw
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)
1843 else:
1844 return super(PusherDomainEnvironmentMixin, self).get_pusher_email()
1847 class StaticRecipientsEnvironmentMixin(Environment):
1848 """Set recipients statically based on constructor parameters."""
1850 def __init__(
1851 self,
1852 refchange_recipients, announce_recipients, revision_recipients,
1853 **kw
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__(
1886 config=config,
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',
1896 **kw
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."""
1910 for name in names:
1911 retval = config.get_recipients(name)
1912 if retval is not None:
1913 return retval
1914 if len(names) == 1:
1915 hint = 'Please set "%s.%s"' % (config.section, name)
1916 else:
1917 hint = (
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()
1941 try:
1942 projectdesc = open(os.path.join(git_dir, 'description')).readline().strip()
1943 if projectdesc and not projectdesc.startswith('Unnamed repository'):
1944 return projectdesc
1945 except IOError:
1946 pass
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,
1964 Environment,
1966 pass
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).
1974 return (
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,
1991 Environment,
1993 pass
1996 class Push(object):
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
2000 a Reference change:
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
2013 references.
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
2034 git rev-list \
2035 ^other1 ^other2 ... \
2036 ^change1.old ^change2.old ... \
2037 change1.new change2.new ...
2039 The commits removed by this push can be computed by
2041 git rev-list \
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
2052 immutable)."""
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.
2061 SORT_ORDER = dict(
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
2082 # by this push:
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(
2087 change.old.sha1
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(
2094 change.new.sha1
2095 for change in self.changes
2096 if change.new.type in ['commit', 'tag']
2100 @classmethod
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:
2108 updated_refs = set(
2109 change.refname
2110 for change in self.changes
2113 # The SHA-1s of commits referred to by all references in this
2114 # repository *except* updated_refs:
2115 sha1s = set()
2116 fmt = (
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:
2123 sha1s.add(sha1)
2125 return sha1s
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
2133 git_objects."""
2135 return ''.join(
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:
2148 new_revs = sorted(
2149 change.new.sha1
2150 for change in self.changes
2151 if change.new
2153 elif not reference_change.new.commit_sha1:
2154 return []
2155 else:
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:
2169 return []
2170 else:
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
2183 body."""
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
2188 # each new commit.
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:
2193 sys.stderr.write(
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,)
2198 else:
2199 sys.stderr.write('Sending notification emails to: %s\n' % (change.recipients,))
2200 mailer.send(change.generate_email(self, body_filter), change.recipients)
2202 sha1s = []
2203 for sha1 in reversed(list(self.get_new_commits(change))):
2204 if sha1 in unhandled_sha1s:
2205 sha1s.append(sha1)
2206 unhandled_sha1s.remove(sha1)
2208 max_emails = change.environment.maxcommitemails
2209 if max_emails and len(sha1s) > max_emails:
2210 sys.stderr.write(
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
2215 return
2217 for (num, sha1) in enumerate(sha1s):
2218 rev = Revision(change, GitObject(sha1), num=num+1, tot=len(sha1s))
2219 if rev.recipients:
2220 mailer.send(rev.generate_email(self, body_filter), rev.recipients)
2222 # Consistency check:
2223 if unhandled_sha1s:
2224 sys.stderr.write(
2225 'ERROR: No emails were sent for the following new commits:\n'
2226 ' %s\n'
2227 % ('\n '.join(sorted(unhandled_sha1s)),)
2231 def run_as_post_receive_hook(environment, mailer):
2232 changes = []
2233 for line in sys.stdin:
2234 (oldrev, newrev, refname) = line.strip().split(' ', 2)
2235 changes.append(
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):
2243 changes = [
2244 ReferenceChange.create(
2245 environment,
2246 read_git_output(['rev-parse', '--verify', oldrev]),
2247 read_git_output(['rev-parse', '--verify', newrev]),
2248 refname,
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')
2266 if command:
2267 command = shlex.split(command)
2268 mailer = SendMailer(command=command, envelopesender=environment.get_sender())
2269 else:
2270 sys.stderr.write(
2271 'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n' % mailer
2272 + 'please use one of "smtp" or "sendmail".\n'
2274 sys.exit(1)
2275 return mailer
2278 KNOWN_ENVIRONMENTS = {
2279 'generic' : GenericEnvironmentMixin,
2280 'gitolite' : GitoliteEnvironmentMixin,
2284 def choose_environment(config, osenv=None, env=None, recipients=None):
2285 if not osenv:
2286 osenv = os.environ
2288 environment_mixins = [
2289 ProjectdescEnvironmentMixin,
2290 ConfigMaxlinesEnvironmentMixin,
2291 ConfigFilterLinesEnvironmentMixin,
2292 PusherDomainEnvironmentMixin,
2293 ConfigOptionsEnvironmentMixin,
2295 environment_kw = {
2296 'osenv' : osenv,
2297 'config' : config,
2300 if not env:
2301 env = config.get('environment')
2303 if not env:
2304 if 'GL_USER' in osenv and 'GL_REPO' in osenv:
2305 env = 'gitolite'
2306 else:
2307 env = 'generic'
2309 environment_mixins.append(KNOWN_ENVIRONMENTS[env])
2311 if recipients:
2312 environment_mixins.insert(0, StaticRecipientsEnvironmentMixin)
2313 environment_kw['refchange_recipients'] = recipients
2314 environment_kw['announce_recipients'] = recipients
2315 environment_kw['revision_recipients'] = recipients
2316 else:
2317 environment_mixins.insert(0, ConfigRecipientsEnvironmentMixin)
2319 environment_klass = type(
2320 'EffectiveEnvironment',
2321 tuple(environment_mixins) + (Environment,),
2324 return environment_klass(**environment_kw)
2327 def main(args):
2328 parser = optparse.OptionParser(
2329 description=__doc__,
2330 usage='%prog [OPTIONS]\n or: %prog [OPTIONS] REFNAME OLDREV NEWREV',
2333 parser.add_option(
2334 '--environment', '--env', action='store', type='choice',
2335 choices=['generic', 'gitolite'], default=None,
2336 help=(
2337 'Choose type of environment is in use. Default is taken from '
2338 'multimailhook.environment if set; otherwise "generic".'
2341 parser.add_option(
2342 '--stdout', action='store_true', default=False,
2343 help='Output emails to stdout rather than sending them.',
2345 parser.add_option(
2346 '--recipients', action='store', default=None,
2347 help='Set list of email recipients for all types of emails.',
2349 parser.add_option(
2350 '--show-env', action='store_true', default=False,
2351 help=(
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')
2361 try:
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')
2374 if options.stdout:
2375 mailer = OutputMailer(sys.stdout)
2376 else:
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.
2381 if args:
2382 if len(args) != 3:
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)
2386 else:
2387 run_as_post_receive_hook(environment, mailer)
2388 except ConfigurationException, e:
2389 sys.exit(str(e))
2392 if __name__ == '__main__':
2393 main(sys.argv[1:])