diff: make -O and --output work in subdirectory
[git.git] / contrib / hooks / multimail / git_multimail.py
blobc06ce7a5158175b684a70e6a148031593a12fb47
1 #! /usr/bin/env python2
3 # Copyright (c) 2015 Matthieu Moy and others
4 # Copyright (c) 2012-2014 Michael Haggerty and others
5 # Derived from contrib/hooks/post-receive-email, which is
6 # Copyright (c) 2007 Andy Parkins
7 # and also includes contributions by other authors.
9 # This file is part of git-multimail.
11 # git-multimail is free software: you can redistribute it and/or
12 # modify it under the terms of the GNU General Public License version
13 # 2 as published by the Free Software Foundation.
15 # This program is distributed in the hope that it will be useful, but
16 # WITHOUT ANY WARRANTY; without even the implied warranty of
17 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
18 # General Public License for more details.
20 # You should have received a copy of the GNU General Public License
21 # along with this program. If not, see
22 # <http://www.gnu.org/licenses/>.
24 """Generate notification emails for pushes to a git repository.
26 This hook sends emails describing changes introduced by pushes to a
27 git repository. For each reference that was changed, it emits one
28 ReferenceChange email summarizing how the reference was changed,
29 followed by one Revision email for each new commit that was introduced
30 by the reference change.
32 Each commit is announced in exactly one Revision email. If the same
33 commit is merged into another branch in the same or a later push, then
34 the ReferenceChange email will list the commit's SHA1 and its one-line
35 summary, but no new Revision email will be generated.
37 This script is designed to be used as a "post-receive" hook in a git
38 repository (see githooks(5)). It can also be used as an "update"
39 script, but this usage is not completely reliable and is deprecated.
41 To help with debugging, this script accepts a --stdout option, which
42 causes the emails to be written to standard output rather than sent
43 using sendmail.
45 See the accompanying README file for the complete documentation.
47 """
49 import sys
50 import os
51 import re
52 import bisect
53 import socket
54 import subprocess
55 import shlex
56 import optparse
57 import smtplib
58 import time
60 try:
61 from email.utils import make_msgid
62 from email.utils import getaddresses
63 from email.utils import formataddr
64 from email.utils import formatdate
65 from email.header import Header
66 except ImportError:
67 # Prior to Python 2.5, the email module used different names:
68 from email.Utils import make_msgid
69 from email.Utils import getaddresses
70 from email.Utils import formataddr
71 from email.Utils import formatdate
72 from email.Header import Header
75 DEBUG = False
77 ZEROS = '0' * 40
78 LOGBEGIN = '- Log -----------------------------------------------------------------\n'
79 LOGEND = '-----------------------------------------------------------------------\n'
81 ADDR_HEADERS = set(['from', 'to', 'cc', 'bcc', 'reply-to', 'sender'])
83 # It is assumed in many places that the encoding is uniformly UTF-8,
84 # so changing these constants is unsupported. But define them here
85 # anyway, to make it easier to find (at least most of) the places
86 # where the encoding is important.
87 (ENCODING, CHARSET) = ('UTF-8', 'utf-8')
90 REF_CREATED_SUBJECT_TEMPLATE = (
91 '%(emailprefix)s%(refname_type)s %(short_refname)s created'
92 ' (now %(newrev_short)s)'
94 REF_UPDATED_SUBJECT_TEMPLATE = (
95 '%(emailprefix)s%(refname_type)s %(short_refname)s updated'
96 ' (%(oldrev_short)s -> %(newrev_short)s)'
98 REF_DELETED_SUBJECT_TEMPLATE = (
99 '%(emailprefix)s%(refname_type)s %(short_refname)s deleted'
100 ' (was %(oldrev_short)s)'
103 COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE = (
104 '%(emailprefix)s%(refname_type)s %(short_refname)s updated: %(oneline)s'
107 REFCHANGE_HEADER_TEMPLATE = """\
108 Date: %(send_date)s
109 To: %(recipients)s
110 Subject: %(subject)s
111 MIME-Version: 1.0
112 Content-Type: text/plain; charset=%(charset)s
113 Content-Transfer-Encoding: 8bit
114 Message-ID: %(msgid)s
115 From: %(fromaddr)s
116 Reply-To: %(reply_to)s
117 X-Git-Host: %(fqdn)s
118 X-Git-Repo: %(repo_shortname)s
119 X-Git-Refname: %(refname)s
120 X-Git-Reftype: %(refname_type)s
121 X-Git-Oldrev: %(oldrev)s
122 X-Git-Newrev: %(newrev)s
123 Auto-Submitted: auto-generated
126 REFCHANGE_INTRO_TEMPLATE = """\
127 This is an automated email from the git hooks/post-receive script.
129 %(pusher)s pushed a change to %(refname_type)s %(short_refname)s
130 in repository %(repo_shortname)s.
135 FOOTER_TEMPLATE = """\
137 -- \n\
138 To stop receiving notification emails like this one, please contact
139 %(administrator)s.
143 REWIND_ONLY_TEMPLATE = """\
144 This update removed existing revisions from the reference, leaving the
145 reference pointing at a previous point in the repository history.
147 * -- * -- N %(refname)s (%(newrev_short)s)
149 O -- O -- O (%(oldrev_short)s)
151 Any revisions marked "omits" are not gone; other references still
152 refer to them. Any revisions marked "discards" are gone forever.
156 NON_FF_TEMPLATE = """\
157 This update added new revisions after undoing existing revisions.
158 That is to say, some revisions that were in the old version of the
159 %(refname_type)s are not in the new version. This situation occurs
160 when a user --force pushes a change and generates a repository
161 containing something like this:
163 * -- * -- B -- O -- O -- O (%(oldrev_short)s)
165 N -- N -- N %(refname)s (%(newrev_short)s)
167 You should already have received notification emails for all of the O
168 revisions, and so the following emails describe only the N revisions
169 from the common base, B.
171 Any revisions marked "omits" are not gone; other references still
172 refer to them. Any revisions marked "discards" are gone forever.
176 NO_NEW_REVISIONS_TEMPLATE = """\
177 No new revisions were added by this update.
181 DISCARDED_REVISIONS_TEMPLATE = """\
182 This change permanently discards the following revisions:
186 NO_DISCARDED_REVISIONS_TEMPLATE = """\
187 The revisions that were on this %(refname_type)s are still contained in
188 other references; therefore, this change does not discard any commits
189 from the repository.
193 NEW_REVISIONS_TEMPLATE = """\
194 The %(tot)s revisions listed above as "new" are entirely new to this
195 repository and will be described in separate emails. The revisions
196 listed as "adds" were already present in the repository and have only
197 been added to this reference.
202 TAG_CREATED_TEMPLATE = """\
203 at %(newrev_short)-9s (%(newrev_type)s)
207 TAG_UPDATED_TEMPLATE = """\
208 *** WARNING: tag %(short_refname)s was modified! ***
210 from %(oldrev_short)-9s (%(oldrev_type)s)
211 to %(newrev_short)-9s (%(newrev_type)s)
215 TAG_DELETED_TEMPLATE = """\
216 *** WARNING: tag %(short_refname)s was deleted! ***
221 # The template used in summary tables. It looks best if this uses the
222 # same alignment as TAG_CREATED_TEMPLATE and TAG_UPDATED_TEMPLATE.
223 BRIEF_SUMMARY_TEMPLATE = """\
224 %(action)10s %(rev_short)-9s %(text)s
228 NON_COMMIT_UPDATE_TEMPLATE = """\
229 This is an unusual reference change because the reference did not
230 refer to a commit either before or after the change. We do not know
231 how to provide full information about this reference change.
235 REVISION_HEADER_TEMPLATE = """\
236 Date: %(send_date)s
237 To: %(recipients)s
238 Cc: %(cc_recipients)s
239 Subject: %(emailprefix)s%(num)02d/%(tot)02d: %(oneline)s
240 MIME-Version: 1.0
241 Content-Type: text/plain; charset=%(charset)s
242 Content-Transfer-Encoding: 8bit
243 From: %(fromaddr)s
244 Reply-To: %(reply_to)s
245 In-Reply-To: %(reply_to_msgid)s
246 References: %(reply_to_msgid)s
247 X-Git-Host: %(fqdn)s
248 X-Git-Repo: %(repo_shortname)s
249 X-Git-Refname: %(refname)s
250 X-Git-Reftype: %(refname_type)s
251 X-Git-Rev: %(rev)s
252 Auto-Submitted: auto-generated
255 REVISION_INTRO_TEMPLATE = """\
256 This is an automated email from the git hooks/post-receive script.
258 %(pusher)s pushed a commit to %(refname_type)s %(short_refname)s
259 in repository %(repo_shortname)s.
264 REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE
267 # Combined, meaning refchange+revision email (for single-commit additions)
268 COMBINED_HEADER_TEMPLATE = """\
269 Date: %(send_date)s
270 To: %(recipients)s
271 Subject: %(subject)s
272 MIME-Version: 1.0
273 Content-Type: text/plain; charset=%(charset)s
274 Content-Transfer-Encoding: 8bit
275 Message-ID: %(msgid)s
276 From: %(fromaddr)s
277 Reply-To: %(reply_to)s
278 X-Git-Host: %(fqdn)s
279 X-Git-Repo: %(repo_shortname)s
280 X-Git-Refname: %(refname)s
281 X-Git-Reftype: %(refname_type)s
282 X-Git-Oldrev: %(oldrev)s
283 X-Git-Newrev: %(newrev)s
284 X-Git-Rev: %(rev)s
285 Auto-Submitted: auto-generated
288 COMBINED_INTRO_TEMPLATE = """\
289 This is an automated email from the git hooks/post-receive script.
291 %(pusher)s pushed a commit to %(refname_type)s %(short_refname)s
292 in repository %(repo_shortname)s.
296 COMBINED_FOOTER_TEMPLATE = FOOTER_TEMPLATE
299 class CommandError(Exception):
300 def __init__(self, cmd, retcode):
301 self.cmd = cmd
302 self.retcode = retcode
303 Exception.__init__(
304 self,
305 'Command "%s" failed with retcode %s' % (' '.join(cmd), retcode,)
309 class ConfigurationException(Exception):
310 pass
313 # The "git" program (this could be changed to include a full path):
314 GIT_EXECUTABLE = 'git'
317 # How "git" should be invoked (including global arguments), as a list
318 # of words. This variable is usually initialized automatically by
319 # read_git_output() via choose_git_command(), but if a value is set
320 # here then it will be used unconditionally.
321 GIT_CMD = None
324 def choose_git_command():
325 """Decide how to invoke git, and record the choice in GIT_CMD."""
327 global GIT_CMD
329 if GIT_CMD is None:
330 try:
331 # Check to see whether the "-c" option is accepted (it was
332 # only added in Git 1.7.2). We don't actually use the
333 # output of "git --version", though if we needed more
334 # specific version information this would be the place to
335 # do it.
336 cmd = [GIT_EXECUTABLE, '-c', 'foo.bar=baz', '--version']
337 read_output(cmd)
338 GIT_CMD = [GIT_EXECUTABLE, '-c', 'i18n.logoutputencoding=%s' % (ENCODING,)]
339 except CommandError:
340 GIT_CMD = [GIT_EXECUTABLE]
343 def read_git_output(args, input=None, keepends=False, **kw):
344 """Read the output of a Git command."""
346 if GIT_CMD is None:
347 choose_git_command()
349 return read_output(GIT_CMD + args, input=input, keepends=keepends, **kw)
352 def read_output(cmd, input=None, keepends=False, **kw):
353 if input:
354 stdin = subprocess.PIPE
355 else:
356 stdin = None
357 p = subprocess.Popen(
358 cmd, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw
360 (out, err) = p.communicate(input)
361 retcode = p.wait()
362 if retcode:
363 raise CommandError(cmd, retcode)
364 if not keepends:
365 out = out.rstrip('\n\r')
366 return out
369 def read_git_lines(args, keepends=False, **kw):
370 """Return the lines output by Git command.
372 Return as single lines, with newlines stripped off."""
374 return read_git_output(args, keepends=True, **kw).splitlines(keepends)
377 def git_rev_list_ish(cmd, spec, args=None, **kw):
378 """Common functionality for invoking a 'git rev-list'-like command.
380 Parameters:
381 * cmd is the Git command to run, e.g., 'rev-list' or 'log'.
382 * spec is a list of revision arguments to pass to the named
383 command. If None, this function returns an empty list.
384 * args is a list of extra arguments passed to the named command.
385 * All other keyword arguments (if any) are passed to the
386 underlying read_git_lines() function.
388 Return the output of the Git command in the form of a list, one
389 entry per output line.
391 if spec is None:
392 return []
393 if args is None:
394 args = []
395 args = [cmd, '--stdin'] + args
396 spec_stdin = ''.join(s + '\n' for s in spec)
397 return read_git_lines(args, input=spec_stdin, **kw)
400 def git_rev_list(spec, **kw):
401 """Run 'git rev-list' with the given list of revision arguments.
403 See git_rev_list_ish() for parameter and return value
404 documentation.
406 return git_rev_list_ish('rev-list', spec, **kw)
409 def git_log(spec, **kw):
410 """Run 'git log' with the given list of revision arguments.
412 See git_rev_list_ish() for parameter and return value
413 documentation.
415 return git_rev_list_ish('log', spec, **kw)
418 def header_encode(text, header_name=None):
419 """Encode and line-wrap the value of an email header field."""
421 try:
422 if isinstance(text, str):
423 text = text.decode(ENCODING, 'replace')
424 return Header(text, header_name=header_name).encode()
425 except UnicodeEncodeError:
426 return Header(text, header_name=header_name, charset=CHARSET,
427 errors='replace').encode()
430 def addr_header_encode(text, header_name=None):
431 """Encode and line-wrap the value of an email header field containing
432 email addresses."""
434 return Header(
435 ', '.join(
436 formataddr((header_encode(name), emailaddr))
437 for name, emailaddr in getaddresses([text])
439 header_name=header_name
440 ).encode()
443 class Config(object):
444 def __init__(self, section, git_config=None):
445 """Represent a section of the git configuration.
447 If git_config is specified, it is passed to "git config" in
448 the GIT_CONFIG environment variable, meaning that "git config"
449 will read the specified path rather than the Git default
450 config paths."""
452 self.section = section
453 if git_config:
454 self.env = os.environ.copy()
455 self.env['GIT_CONFIG'] = git_config
456 else:
457 self.env = None
459 @staticmethod
460 def _split(s):
461 """Split NUL-terminated values."""
463 words = s.split('\0')
464 assert words[-1] == ''
465 return words[:-1]
467 def get(self, name, default=None):
468 try:
469 values = self._split(read_git_output(
470 ['config', '--get', '--null', '%s.%s' % (self.section, name)],
471 env=self.env, keepends=True,
473 assert len(values) == 1
474 return values[0]
475 except CommandError:
476 return default
478 def get_bool(self, name, default=None):
479 try:
480 value = read_git_output(
481 ['config', '--get', '--bool', '%s.%s' % (self.section, name)],
482 env=self.env,
484 except CommandError:
485 return default
486 return value == 'true'
488 def get_all(self, name, default=None):
489 """Read a (possibly multivalued) setting from the configuration.
491 Return the result as a list of values, or default if the name
492 is unset."""
494 try:
495 return self._split(read_git_output(
496 ['config', '--get-all', '--null', '%s.%s' % (self.section, name)],
497 env=self.env, keepends=True,
499 except CommandError, e:
500 if e.retcode == 1:
501 # "the section or key is invalid"; i.e., there is no
502 # value for the specified key.
503 return default
504 else:
505 raise
507 def get_recipients(self, name, default=None):
508 """Read a recipients list from the configuration.
510 Return the result as a comma-separated list of email
511 addresses, or default if the option is unset. If the setting
512 has multiple values, concatenate them with comma separators."""
514 lines = self.get_all(name, default=None)
515 if lines is None:
516 return default
517 return ', '.join(line.strip() for line in lines)
519 def set(self, name, value):
520 read_git_output(
521 ['config', '%s.%s' % (self.section, name), value],
522 env=self.env,
525 def add(self, name, value):
526 read_git_output(
527 ['config', '--add', '%s.%s' % (self.section, name), value],
528 env=self.env,
531 def __contains__(self, name):
532 return self.get_all(name, default=None) is not None
534 # We don't use this method anymore internally, but keep it here in
535 # case somebody is calling it from their own code:
536 def has_key(self, name):
537 return name in self
539 def unset_all(self, name):
540 try:
541 read_git_output(
542 ['config', '--unset-all', '%s.%s' % (self.section, name)],
543 env=self.env,
545 except CommandError, e:
546 if e.retcode == 5:
547 # The name doesn't exist, which is what we wanted anyway...
548 pass
549 else:
550 raise
552 def set_recipients(self, name, value):
553 self.unset_all(name)
554 for pair in getaddresses([value]):
555 self.add(name, formataddr(pair))
558 def generate_summaries(*log_args):
559 """Generate a brief summary for each revision requested.
561 log_args are strings that will be passed directly to "git log" as
562 revision selectors. Iterate over (sha1_short, subject) for each
563 commit specified by log_args (subject is the first line of the
564 commit message as a string without EOLs)."""
566 cmd = [
567 'log', '--abbrev', '--format=%h %s',
568 ] + list(log_args) + ['--']
569 for line in read_git_lines(cmd):
570 yield tuple(line.split(' ', 1))
573 def limit_lines(lines, max_lines):
574 for (index, line) in enumerate(lines):
575 if index < max_lines:
576 yield line
578 if index >= max_lines:
579 yield '... %d lines suppressed ...\n' % (index + 1 - max_lines,)
582 def limit_linelength(lines, max_linelength):
583 for line in lines:
584 # Don't forget that lines always include a trailing newline.
585 if len(line) > max_linelength + 1:
586 line = line[:max_linelength - 7] + ' [...]\n'
587 yield line
590 class CommitSet(object):
591 """A (constant) set of object names.
593 The set should be initialized with full SHA1 object names. The
594 __contains__() method returns True iff its argument is an
595 abbreviation of any the names in the set."""
597 def __init__(self, names):
598 self._names = sorted(names)
600 def __len__(self):
601 return len(self._names)
603 def __contains__(self, sha1_abbrev):
604 """Return True iff this set contains sha1_abbrev (which might be abbreviated)."""
606 i = bisect.bisect_left(self._names, sha1_abbrev)
607 return i < len(self) and self._names[i].startswith(sha1_abbrev)
610 class GitObject(object):
611 def __init__(self, sha1, type=None):
612 if sha1 == ZEROS:
613 self.sha1 = self.type = self.commit_sha1 = None
614 else:
615 self.sha1 = sha1
616 self.type = type or read_git_output(['cat-file', '-t', self.sha1])
618 if self.type == 'commit':
619 self.commit_sha1 = self.sha1
620 elif self.type == 'tag':
621 try:
622 self.commit_sha1 = read_git_output(
623 ['rev-parse', '--verify', '%s^0' % (self.sha1,)]
625 except CommandError:
626 # Cannot deref tag to determine commit_sha1
627 self.commit_sha1 = None
628 else:
629 self.commit_sha1 = None
631 self.short = read_git_output(['rev-parse', '--short', sha1])
633 def get_summary(self):
634 """Return (sha1_short, subject) for this commit."""
636 if not self.sha1:
637 raise ValueError('Empty commit has no summary')
639 return iter(generate_summaries('--no-walk', self.sha1)).next()
641 def __eq__(self, other):
642 return isinstance(other, GitObject) and self.sha1 == other.sha1
644 def __hash__(self):
645 return hash(self.sha1)
647 def __nonzero__(self):
648 return bool(self.sha1)
650 def __str__(self):
651 return self.sha1 or ZEROS
654 class Change(object):
655 """A Change that has been made to the Git repository.
657 Abstract class from which both Revisions and ReferenceChanges are
658 derived. A Change knows how to generate a notification email
659 describing itself."""
661 def __init__(self, environment):
662 self.environment = environment
663 self._values = None
665 def _compute_values(self):
666 """Return a dictionary {keyword: expansion} for this Change.
668 Derived classes overload this method to add more entries to
669 the return value. This method is used internally by
670 get_values(). The return value should always be a new
671 dictionary."""
673 return self.environment.get_values()
675 def get_values(self, **extra_values):
676 """Return a dictionary {keyword: expansion} for this Change.
678 Return a dictionary mapping keywords to the values that they
679 should be expanded to for this Change (used when interpolating
680 template strings). If any keyword arguments are supplied, add
681 those to the return value as well. The return value is always
682 a new dictionary."""
684 if self._values is None:
685 self._values = self._compute_values()
687 values = self._values.copy()
688 if extra_values:
689 values.update(extra_values)
690 return values
692 def expand(self, template, **extra_values):
693 """Expand template.
695 Expand the template (which should be a string) using string
696 interpolation of the values for this Change. If any keyword
697 arguments are provided, also include those in the keywords
698 available for interpolation."""
700 return template % self.get_values(**extra_values)
702 def expand_lines(self, template, **extra_values):
703 """Break template into lines and expand each line."""
705 values = self.get_values(**extra_values)
706 for line in template.splitlines(True):
707 yield line % values
709 def expand_header_lines(self, template, **extra_values):
710 """Break template into lines and expand each line as an RFC 2822 header.
712 Encode values and split up lines that are too long. Silently
713 skip lines that contain references to unknown variables."""
715 values = self.get_values(**extra_values)
716 for line in template.splitlines():
717 (name, value) = line.split(':', 1)
719 try:
720 value = value % values
721 except KeyError, e:
722 if DEBUG:
723 self.environment.log_warning(
724 'Warning: unknown variable %r in the following line; line skipped:\n'
725 ' %s\n'
726 % (e.args[0], line,)
728 else:
729 if name.lower() in ADDR_HEADERS:
730 value = addr_header_encode(value, name)
731 else:
732 value = header_encode(value, name)
733 for splitline in ('%s: %s\n' % (name, value)).splitlines(True):
734 yield splitline
736 def generate_email_header(self):
737 """Generate the RFC 2822 email headers for this Change, a line at a time.
739 The output should not include the trailing blank line."""
741 raise NotImplementedError()
743 def generate_email_intro(self):
744 """Generate the email intro for this Change, a line at a time.
746 The output will be used as the standard boilerplate at the top
747 of the email body."""
749 raise NotImplementedError()
751 def generate_email_body(self):
752 """Generate the main part of the email body, a line at a time.
754 The text in the body might be truncated after a specified
755 number of lines (see multimailhook.emailmaxlines)."""
757 raise NotImplementedError()
759 def generate_email_footer(self):
760 """Generate the footer of the email, a line at a time.
762 The footer is always included, irrespective of
763 multimailhook.emailmaxlines."""
765 raise NotImplementedError()
767 def generate_email(self, push, body_filter=None, extra_header_values={}):
768 """Generate an email describing this change.
770 Iterate over the lines (including the header lines) of an
771 email describing this change. If body_filter is not None,
772 then use it to filter the lines that are intended for the
773 email body.
775 The extra_header_values field is received as a dict and not as
776 **kwargs, to allow passing other keyword arguments in the
777 future (e.g. passing extra values to generate_email_intro()"""
779 for line in self.generate_email_header(**extra_header_values):
780 yield line
781 yield '\n'
782 for line in self.generate_email_intro():
783 yield line
785 body = self.generate_email_body(push)
786 if body_filter is not None:
787 body = body_filter(body)
788 for line in body:
789 yield line
791 for line in self.generate_email_footer():
792 yield line
795 class Revision(Change):
796 """A Change consisting of a single git commit."""
798 CC_RE = re.compile(r'^\s*C[Cc]:\s*(?P<to>[^#]+@[^\s#]*)\s*(#.*)?$')
800 def __init__(self, reference_change, rev, num, tot):
801 Change.__init__(self, reference_change.environment)
802 self.reference_change = reference_change
803 self.rev = rev
804 self.change_type = self.reference_change.change_type
805 self.refname = self.reference_change.refname
806 self.num = num
807 self.tot = tot
808 self.author = read_git_output(['log', '--no-walk', '--format=%aN <%aE>', self.rev.sha1])
809 self.recipients = self.environment.get_revision_recipients(self)
811 self.cc_recipients = ''
812 if self.environment.get_scancommitforcc():
813 self.cc_recipients = ', '.join(to.strip() for to in self._cc_recipients())
814 if self.cc_recipients:
815 self.environment.log_msg(
816 'Add %s to CC for %s\n' % (self.cc_recipients, self.rev.sha1))
818 def _cc_recipients(self):
819 cc_recipients = []
820 message = read_git_output(['log', '--no-walk', '--format=%b', self.rev.sha1])
821 lines = message.strip().split('\n')
822 for line in lines:
823 m = re.match(self.CC_RE, line)
824 if m:
825 cc_recipients.append(m.group('to'))
827 return cc_recipients
829 def _compute_values(self):
830 values = Change._compute_values(self)
832 oneline = read_git_output(
833 ['log', '--format=%s', '--no-walk', self.rev.sha1]
836 values['rev'] = self.rev.sha1
837 values['rev_short'] = self.rev.short
838 values['change_type'] = self.change_type
839 values['refname'] = self.refname
840 values['short_refname'] = self.reference_change.short_refname
841 values['refname_type'] = self.reference_change.refname_type
842 values['reply_to_msgid'] = self.reference_change.msgid
843 values['num'] = self.num
844 values['tot'] = self.tot
845 values['recipients'] = self.recipients
846 if self.cc_recipients:
847 values['cc_recipients'] = self.cc_recipients
848 values['oneline'] = oneline
849 values['author'] = self.author
851 reply_to = self.environment.get_reply_to_commit(self)
852 if reply_to:
853 values['reply_to'] = reply_to
855 return values
857 def generate_email_header(self, **extra_values):
858 for line in self.expand_header_lines(
859 REVISION_HEADER_TEMPLATE, **extra_values
861 yield line
863 def generate_email_intro(self):
864 for line in self.expand_lines(REVISION_INTRO_TEMPLATE):
865 yield line
867 def generate_email_body(self, push):
868 """Show this revision."""
870 return read_git_lines(
871 ['log'] + self.environment.commitlogopts + ['-1', self.rev.sha1],
872 keepends=True,
875 def generate_email_footer(self):
876 return self.expand_lines(REVISION_FOOTER_TEMPLATE)
879 class ReferenceChange(Change):
880 """A Change to a Git reference.
882 An abstract class representing a create, update, or delete of a
883 Git reference. Derived classes handle specific types of reference
884 (e.g., tags vs. branches). These classes generate the main
885 reference change email summarizing the reference change and
886 whether it caused any any commits to be added or removed.
888 ReferenceChange objects are usually created using the static
889 create() method, which has the logic to decide which derived class
890 to instantiate."""
892 REF_RE = re.compile(r'^refs\/(?P<area>[^\/]+)\/(?P<shortname>.*)$')
894 @staticmethod
895 def create(environment, oldrev, newrev, refname):
896 """Return a ReferenceChange object representing the change.
898 Return an object that represents the type of change that is being
899 made. oldrev and newrev should be SHA1s or ZEROS."""
901 old = GitObject(oldrev)
902 new = GitObject(newrev)
903 rev = new or old
905 # The revision type tells us what type the commit is, combined with
906 # the location of the ref we can decide between
907 # - working branch
908 # - tracking branch
909 # - unannotated tag
910 # - annotated tag
911 m = ReferenceChange.REF_RE.match(refname)
912 if m:
913 area = m.group('area')
914 short_refname = m.group('shortname')
915 else:
916 area = ''
917 short_refname = refname
919 if rev.type == 'tag':
920 # Annotated tag:
921 klass = AnnotatedTagChange
922 elif rev.type == 'commit':
923 if area == 'tags':
924 # Non-annotated tag:
925 klass = NonAnnotatedTagChange
926 elif area == 'heads':
927 # Branch:
928 klass = BranchChange
929 elif area == 'remotes':
930 # Tracking branch:
931 environment.log_warning(
932 '*** Push-update of tracking branch %r\n'
933 '*** - incomplete email generated.\n'
934 % (refname,)
936 klass = OtherReferenceChange
937 else:
938 # Some other reference namespace:
939 environment.log_warning(
940 '*** Push-update of strange reference %r\n'
941 '*** - incomplete email generated.\n'
942 % (refname,)
944 klass = OtherReferenceChange
945 else:
946 # Anything else (is there anything else?)
947 environment.log_warning(
948 '*** Unknown type of update to %r (%s)\n'
949 '*** - incomplete email generated.\n'
950 % (refname, rev.type,)
952 klass = OtherReferenceChange
954 return klass(
955 environment,
956 refname=refname, short_refname=short_refname,
957 old=old, new=new, rev=rev,
960 def __init__(self, environment, refname, short_refname, old, new, rev):
961 Change.__init__(self, environment)
962 self.change_type = {
963 (False, True): 'create',
964 (True, True): 'update',
965 (True, False): 'delete',
966 }[bool(old), bool(new)]
967 self.refname = refname
968 self.short_refname = short_refname
969 self.old = old
970 self.new = new
971 self.rev = rev
972 self.msgid = make_msgid()
973 self.diffopts = environment.diffopts
974 self.graphopts = environment.graphopts
975 self.logopts = environment.logopts
976 self.commitlogopts = environment.commitlogopts
977 self.showgraph = environment.refchange_showgraph
978 self.showlog = environment.refchange_showlog
980 self.header_template = REFCHANGE_HEADER_TEMPLATE
981 self.intro_template = REFCHANGE_INTRO_TEMPLATE
982 self.footer_template = FOOTER_TEMPLATE
984 def _compute_values(self):
985 values = Change._compute_values(self)
987 values['change_type'] = self.change_type
988 values['refname_type'] = self.refname_type
989 values['refname'] = self.refname
990 values['short_refname'] = self.short_refname
991 values['msgid'] = self.msgid
992 values['recipients'] = self.recipients
993 values['oldrev'] = str(self.old)
994 values['oldrev_short'] = self.old.short
995 values['newrev'] = str(self.new)
996 values['newrev_short'] = self.new.short
998 if self.old:
999 values['oldrev_type'] = self.old.type
1000 if self.new:
1001 values['newrev_type'] = self.new.type
1003 reply_to = self.environment.get_reply_to_refchange(self)
1004 if reply_to:
1005 values['reply_to'] = reply_to
1007 return values
1009 def send_single_combined_email(self, known_added_sha1s):
1010 """Determine if a combined refchange/revision email should be sent
1012 If there is only a single new (non-merge) commit added by a
1013 change, it is useful to combine the ReferenceChange and
1014 Revision emails into one. In such a case, return the single
1015 revision; otherwise, return None.
1017 This method is overridden in BranchChange."""
1019 return None
1021 def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):
1022 """Generate an email describing this change AND specified revision.
1024 Iterate over the lines (including the header lines) of an
1025 email describing this change. If body_filter is not None,
1026 then use it to filter the lines that are intended for the
1027 email body.
1029 The extra_header_values field is received as a dict and not as
1030 **kwargs, to allow passing other keyword arguments in the
1031 future (e.g. passing extra values to generate_email_intro()
1033 This method is overridden in BranchChange."""
1035 raise NotImplementedError
1037 def get_subject(self):
1038 template = {
1039 'create': REF_CREATED_SUBJECT_TEMPLATE,
1040 'update': REF_UPDATED_SUBJECT_TEMPLATE,
1041 'delete': REF_DELETED_SUBJECT_TEMPLATE,
1042 }[self.change_type]
1043 return self.expand(template)
1045 def generate_email_header(self, **extra_values):
1046 if 'subject' not in extra_values:
1047 extra_values['subject'] = self.get_subject()
1049 for line in self.expand_header_lines(
1050 self.header_template, **extra_values
1052 yield line
1054 def generate_email_intro(self):
1055 for line in self.expand_lines(self.intro_template):
1056 yield line
1058 def generate_email_body(self, push):
1059 """Call the appropriate body-generation routine.
1061 Call one of generate_create_summary() /
1062 generate_update_summary() / generate_delete_summary()."""
1064 change_summary = {
1065 'create': self.generate_create_summary,
1066 'delete': self.generate_delete_summary,
1067 'update': self.generate_update_summary,
1068 }[self.change_type](push)
1069 for line in change_summary:
1070 yield line
1072 for line in self.generate_revision_change_summary(push):
1073 yield line
1075 def generate_email_footer(self):
1076 return self.expand_lines(self.footer_template)
1078 def generate_revision_change_graph(self, push):
1079 if self.showgraph:
1080 args = ['--graph'] + self.graphopts
1081 for newold in ('new', 'old'):
1082 has_newold = False
1083 spec = push.get_commits_spec(newold, self)
1084 for line in git_log(spec, args=args, keepends=True):
1085 if not has_newold:
1086 has_newold = True
1087 yield '\n'
1088 yield 'Graph of %s commits:\n\n' % (
1089 {'new': 'new', 'old': 'discarded'}[newold],)
1090 yield ' ' + line
1091 if has_newold:
1092 yield '\n'
1094 def generate_revision_change_log(self, new_commits_list):
1095 if self.showlog:
1096 yield '\n'
1097 yield 'Detailed log of new commits:\n\n'
1098 for line in read_git_lines(
1099 ['log', '--no-walk']
1100 + self.logopts
1101 + new_commits_list
1102 + ['--'],
1103 keepends=True,
1105 yield line
1107 def generate_new_revision_summary(self, tot, new_commits_list, push):
1108 for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot):
1109 yield line
1110 for line in self.generate_revision_change_graph(push):
1111 yield line
1112 for line in self.generate_revision_change_log(new_commits_list):
1113 yield line
1115 def generate_revision_change_summary(self, push):
1116 """Generate a summary of the revisions added/removed by this change."""
1118 if self.new.commit_sha1 and not self.old.commit_sha1:
1119 # A new reference was created. List the new revisions
1120 # brought by the new reference (i.e., those revisions that
1121 # were not in the repository before this reference
1122 # change).
1123 sha1s = list(push.get_new_commits(self))
1124 sha1s.reverse()
1125 tot = len(sha1s)
1126 new_revisions = [
1127 Revision(self, GitObject(sha1), num=i + 1, tot=tot)
1128 for (i, sha1) in enumerate(sha1s)
1131 if new_revisions:
1132 yield self.expand('This %(refname_type)s includes the following new commits:\n')
1133 yield '\n'
1134 for r in new_revisions:
1135 (sha1, subject) = r.rev.get_summary()
1136 yield r.expand(
1137 BRIEF_SUMMARY_TEMPLATE, action='new', text=subject,
1139 yield '\n'
1140 for line in self.generate_new_revision_summary(
1141 tot, [r.rev.sha1 for r in new_revisions], push):
1142 yield line
1143 else:
1144 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
1145 yield line
1147 elif self.new.commit_sha1 and self.old.commit_sha1:
1148 # A reference was changed to point at a different commit.
1149 # List the revisions that were removed and/or added *from
1150 # that reference* by this reference change, along with a
1151 # diff between the trees for its old and new values.
1153 # List of the revisions that were added to the branch by
1154 # this update. Note this list can include revisions that
1155 # have already had notification emails; we want such
1156 # revisions in the summary even though we will not send
1157 # new notification emails for them.
1158 adds = list(generate_summaries(
1159 '--topo-order', '--reverse', '%s..%s'
1160 % (self.old.commit_sha1, self.new.commit_sha1,)
1163 # List of the revisions that were removed from the branch
1164 # by this update. This will be empty except for
1165 # non-fast-forward updates.
1166 discards = list(generate_summaries(
1167 '%s..%s' % (self.new.commit_sha1, self.old.commit_sha1,)
1170 if adds:
1171 new_commits_list = push.get_new_commits(self)
1172 else:
1173 new_commits_list = []
1174 new_commits = CommitSet(new_commits_list)
1176 if discards:
1177 discarded_commits = CommitSet(push.get_discarded_commits(self))
1178 else:
1179 discarded_commits = CommitSet([])
1181 if discards and adds:
1182 for (sha1, subject) in discards:
1183 if sha1 in discarded_commits:
1184 action = 'discards'
1185 else:
1186 action = 'omits'
1187 yield self.expand(
1188 BRIEF_SUMMARY_TEMPLATE, action=action,
1189 rev_short=sha1, text=subject,
1191 for (sha1, subject) in adds:
1192 if sha1 in new_commits:
1193 action = 'new'
1194 else:
1195 action = 'adds'
1196 yield self.expand(
1197 BRIEF_SUMMARY_TEMPLATE, action=action,
1198 rev_short=sha1, text=subject,
1200 yield '\n'
1201 for line in self.expand_lines(NON_FF_TEMPLATE):
1202 yield line
1204 elif discards:
1205 for (sha1, subject) in discards:
1206 if sha1 in discarded_commits:
1207 action = 'discards'
1208 else:
1209 action = 'omits'
1210 yield self.expand(
1211 BRIEF_SUMMARY_TEMPLATE, action=action,
1212 rev_short=sha1, text=subject,
1214 yield '\n'
1215 for line in self.expand_lines(REWIND_ONLY_TEMPLATE):
1216 yield line
1218 elif adds:
1219 (sha1, subject) = self.old.get_summary()
1220 yield self.expand(
1221 BRIEF_SUMMARY_TEMPLATE, action='from',
1222 rev_short=sha1, text=subject,
1224 for (sha1, subject) in adds:
1225 if sha1 in new_commits:
1226 action = 'new'
1227 else:
1228 action = 'adds'
1229 yield self.expand(
1230 BRIEF_SUMMARY_TEMPLATE, action=action,
1231 rev_short=sha1, text=subject,
1234 yield '\n'
1236 if new_commits:
1237 for line in self.generate_new_revision_summary(
1238 len(new_commits), new_commits_list, push):
1239 yield line
1240 else:
1241 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
1242 yield line
1243 for line in self.generate_revision_change_graph(push):
1244 yield line
1246 # The diffstat is shown from the old revision to the new
1247 # revision. This is to show the truth of what happened in
1248 # this change. There's no point showing the stat from the
1249 # base to the new revision because the base is effectively a
1250 # random revision at this point - the user will be interested
1251 # in what this revision changed - including the undoing of
1252 # previous revisions in the case of non-fast-forward updates.
1253 yield '\n'
1254 yield 'Summary of changes:\n'
1255 for line in read_git_lines(
1256 ['diff-tree']
1257 + self.diffopts
1258 + ['%s..%s' % (self.old.commit_sha1, self.new.commit_sha1,)],
1259 keepends=True,
1261 yield line
1263 elif self.old.commit_sha1 and not self.new.commit_sha1:
1264 # A reference was deleted. List the revisions that were
1265 # removed from the repository by this reference change.
1267 sha1s = list(push.get_discarded_commits(self))
1268 tot = len(sha1s)
1269 discarded_revisions = [
1270 Revision(self, GitObject(sha1), num=i + 1, tot=tot)
1271 for (i, sha1) in enumerate(sha1s)
1274 if discarded_revisions:
1275 for line in self.expand_lines(DISCARDED_REVISIONS_TEMPLATE):
1276 yield line
1277 yield '\n'
1278 for r in discarded_revisions:
1279 (sha1, subject) = r.rev.get_summary()
1280 yield r.expand(
1281 BRIEF_SUMMARY_TEMPLATE, action='discards', text=subject,
1283 for line in self.generate_revision_change_graph(push):
1284 yield line
1285 else:
1286 for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE):
1287 yield line
1289 elif not self.old.commit_sha1 and not self.new.commit_sha1:
1290 for line in self.expand_lines(NON_COMMIT_UPDATE_TEMPLATE):
1291 yield line
1293 def generate_create_summary(self, push):
1294 """Called for the creation of a reference."""
1296 # This is a new reference and so oldrev is not valid
1297 (sha1, subject) = self.new.get_summary()
1298 yield self.expand(
1299 BRIEF_SUMMARY_TEMPLATE, action='at',
1300 rev_short=sha1, text=subject,
1302 yield '\n'
1304 def generate_update_summary(self, push):
1305 """Called for the change of a pre-existing branch."""
1307 return iter([])
1309 def generate_delete_summary(self, push):
1310 """Called for the deletion of any type of reference."""
1312 (sha1, subject) = self.old.get_summary()
1313 yield self.expand(
1314 BRIEF_SUMMARY_TEMPLATE, action='was',
1315 rev_short=sha1, text=subject,
1317 yield '\n'
1320 class BranchChange(ReferenceChange):
1321 refname_type = 'branch'
1323 def __init__(self, environment, refname, short_refname, old, new, rev):
1324 ReferenceChange.__init__(
1325 self, environment,
1326 refname=refname, short_refname=short_refname,
1327 old=old, new=new, rev=rev,
1329 self.recipients = environment.get_refchange_recipients(self)
1330 self._single_revision = None
1332 def send_single_combined_email(self, known_added_sha1s):
1333 if not self.environment.combine_when_single_commit:
1334 return None
1336 # In the sadly-all-too-frequent usecase of people pushing only
1337 # one of their commits at a time to a repository, users feel
1338 # the reference change summary emails are noise rather than
1339 # important signal. This is because, in this particular
1340 # usecase, there is a reference change summary email for each
1341 # new commit, and all these summaries do is point out that
1342 # there is one new commit (which can readily be inferred by
1343 # the existence of the individual revision email that is also
1344 # sent). In such cases, our users prefer there to be a combined
1345 # reference change summary/new revision email.
1347 # So, if the change is an update and it doesn't discard any
1348 # commits, and it adds exactly one non-merge commit (gerrit
1349 # forces a workflow where every commit is individually merged
1350 # and the git-multimail hook fired off for just this one
1351 # change), then we send a combined refchange/revision email.
1352 try:
1353 # If this change is a reference update that doesn't discard
1354 # any commits...
1355 if self.change_type != 'update':
1356 return None
1358 if read_git_lines(
1359 ['merge-base', self.old.sha1, self.new.sha1]
1360 ) != [self.old.sha1]:
1361 return None
1363 # Check if this update introduced exactly one non-merge
1364 # commit:
1366 def split_line(line):
1367 """Split line into (sha1, [parent,...])."""
1369 words = line.split()
1370 return (words[0], words[1:])
1372 # Get the new commits introduced by the push as a list of
1373 # (sha1, [parent,...])
1374 new_commits = [
1375 split_line(line)
1376 for line in read_git_lines(
1378 'log', '-3', '--format=%H %P',
1379 '%s..%s' % (self.old.sha1, self.new.sha1),
1384 if not new_commits:
1385 return None
1387 # If the newest commit is a merge, save it for a later check
1388 # but otherwise ignore it
1389 merge = None
1390 tot = len(new_commits)
1391 if len(new_commits[0][1]) > 1:
1392 merge = new_commits[0][0]
1393 del new_commits[0]
1395 # Our primary check: we can't combine if more than one commit
1396 # is introduced. We also currently only combine if the new
1397 # commit is a non-merge commit, though it may make sense to
1398 # combine if it is a merge as well.
1399 if not (
1400 len(new_commits) == 1
1401 and len(new_commits[0][1]) == 1
1402 and new_commits[0][0] in known_added_sha1s
1404 return None
1406 # We do not want to combine revision and refchange emails if
1407 # those go to separate locations.
1408 rev = Revision(self, GitObject(new_commits[0][0]), 1, tot)
1409 if rev.recipients != self.recipients:
1410 return None
1412 # We ignored the newest commit if it was just a merge of the one
1413 # commit being introduced. But we don't want to ignore that
1414 # merge commit it it involved conflict resolutions. Check that.
1415 if merge and merge != read_git_output(['diff-tree', '--cc', merge]):
1416 return None
1418 # We can combine the refchange and one new revision emails
1419 # into one. Return the Revision that a combined email should
1420 # be sent about.
1421 return rev
1422 except CommandError:
1423 # Cannot determine number of commits in old..new or new..old;
1424 # don't combine reference/revision emails:
1425 return None
1427 def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):
1428 values = revision.get_values()
1429 if extra_header_values:
1430 values.update(extra_header_values)
1431 if 'subject' not in extra_header_values:
1432 values['subject'] = self.expand(COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE, **values)
1434 self._single_revision = revision
1435 self.header_template = COMBINED_HEADER_TEMPLATE
1436 self.intro_template = COMBINED_INTRO_TEMPLATE
1437 self.footer_template = COMBINED_FOOTER_TEMPLATE
1438 for line in self.generate_email(push, body_filter, values):
1439 yield line
1441 def generate_email_body(self, push):
1442 '''Call the appropriate body generation routine.
1444 If this is a combined refchange/revision email, the special logic
1445 for handling this combined email comes from this function. For
1446 other cases, we just use the normal handling.'''
1448 # If self._single_revision isn't set; don't override
1449 if not self._single_revision:
1450 for line in super(BranchChange, self).generate_email_body(push):
1451 yield line
1452 return
1454 # This is a combined refchange/revision email; we first provide
1455 # some info from the refchange portion, and then call the revision
1456 # generate_email_body function to handle the revision portion.
1457 adds = list(generate_summaries(
1458 '--topo-order', '--reverse', '%s..%s'
1459 % (self.old.commit_sha1, self.new.commit_sha1,)
1462 yield self.expand("The following commit(s) were added to %(refname)s by this push:\n")
1463 for (sha1, subject) in adds:
1464 yield self.expand(
1465 BRIEF_SUMMARY_TEMPLATE, action='new',
1466 rev_short=sha1, text=subject,
1469 yield self._single_revision.rev.short + " is described below\n"
1470 yield '\n'
1472 for line in self._single_revision.generate_email_body(push):
1473 yield line
1476 class AnnotatedTagChange(ReferenceChange):
1477 refname_type = 'annotated tag'
1479 def __init__(self, environment, refname, short_refname, old, new, rev):
1480 ReferenceChange.__init__(
1481 self, environment,
1482 refname=refname, short_refname=short_refname,
1483 old=old, new=new, rev=rev,
1485 self.recipients = environment.get_announce_recipients(self)
1486 self.show_shortlog = environment.announce_show_shortlog
1488 ANNOTATED_TAG_FORMAT = (
1489 '%(*objectname)\n'
1490 '%(*objecttype)\n'
1491 '%(taggername)\n'
1492 '%(taggerdate)'
1495 def describe_tag(self, push):
1496 """Describe the new value of an annotated tag."""
1498 # Use git for-each-ref to pull out the individual fields from
1499 # the tag
1500 [tagobject, tagtype, tagger, tagged] = read_git_lines(
1501 ['for-each-ref', '--format=%s' % (self.ANNOTATED_TAG_FORMAT,), self.refname],
1504 yield self.expand(
1505 BRIEF_SUMMARY_TEMPLATE, action='tagging',
1506 rev_short=tagobject, text='(%s)' % (tagtype,),
1508 if tagtype == 'commit':
1509 # If the tagged object is a commit, then we assume this is a
1510 # release, and so we calculate which tag this tag is
1511 # replacing
1512 try:
1513 prevtag = read_git_output(['describe', '--abbrev=0', '%s^' % (self.new,)])
1514 except CommandError:
1515 prevtag = None
1516 if prevtag:
1517 yield ' replaces %s\n' % (prevtag,)
1518 else:
1519 prevtag = None
1520 yield ' length %s bytes\n' % (read_git_output(['cat-file', '-s', tagobject]),)
1522 yield ' tagged by %s\n' % (tagger,)
1523 yield ' on %s\n' % (tagged,)
1524 yield '\n'
1526 # Show the content of the tag message; this might contain a
1527 # change log or release notes so is worth displaying.
1528 yield LOGBEGIN
1529 contents = list(read_git_lines(['cat-file', 'tag', self.new.sha1], keepends=True))
1530 contents = contents[contents.index('\n') + 1:]
1531 if contents and contents[-1][-1:] != '\n':
1532 contents.append('\n')
1533 for line in contents:
1534 yield line
1536 if self.show_shortlog and tagtype == 'commit':
1537 # Only commit tags make sense to have rev-list operations
1538 # performed on them
1539 yield '\n'
1540 if prevtag:
1541 # Show changes since the previous release
1542 revlist = read_git_output(
1543 ['rev-list', '--pretty=short', '%s..%s' % (prevtag, self.new,)],
1544 keepends=True,
1546 else:
1547 # No previous tag, show all the changes since time
1548 # began
1549 revlist = read_git_output(
1550 ['rev-list', '--pretty=short', '%s' % (self.new,)],
1551 keepends=True,
1553 for line in read_git_lines(['shortlog'], input=revlist, keepends=True):
1554 yield line
1556 yield LOGEND
1557 yield '\n'
1559 def generate_create_summary(self, push):
1560 """Called for the creation of an annotated tag."""
1562 for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1563 yield line
1565 for line in self.describe_tag(push):
1566 yield line
1568 def generate_update_summary(self, push):
1569 """Called for the update of an annotated tag.
1571 This is probably a rare event and may not even be allowed."""
1573 for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1574 yield line
1576 for line in self.describe_tag(push):
1577 yield line
1579 def generate_delete_summary(self, push):
1580 """Called when a non-annotated reference is updated."""
1582 for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1583 yield line
1585 yield self.expand(' tag was %(oldrev_short)s\n')
1586 yield '\n'
1589 class NonAnnotatedTagChange(ReferenceChange):
1590 refname_type = 'tag'
1592 def __init__(self, environment, refname, short_refname, old, new, rev):
1593 ReferenceChange.__init__(
1594 self, environment,
1595 refname=refname, short_refname=short_refname,
1596 old=old, new=new, rev=rev,
1598 self.recipients = environment.get_refchange_recipients(self)
1600 def generate_create_summary(self, push):
1601 """Called for the creation of an annotated tag."""
1603 for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1604 yield line
1606 def generate_update_summary(self, push):
1607 """Called when a non-annotated reference is updated."""
1609 for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1610 yield line
1612 def generate_delete_summary(self, push):
1613 """Called when a non-annotated reference is updated."""
1615 for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1616 yield line
1618 for line in ReferenceChange.generate_delete_summary(self, push):
1619 yield line
1622 class OtherReferenceChange(ReferenceChange):
1623 refname_type = 'reference'
1625 def __init__(self, environment, refname, short_refname, old, new, rev):
1626 # We use the full refname as short_refname, because otherwise
1627 # the full name of the reference would not be obvious from the
1628 # text of the email.
1629 ReferenceChange.__init__(
1630 self, environment,
1631 refname=refname, short_refname=refname,
1632 old=old, new=new, rev=rev,
1634 self.recipients = environment.get_refchange_recipients(self)
1637 class Mailer(object):
1638 """An object that can send emails."""
1640 def send(self, lines, to_addrs):
1641 """Send an email consisting of lines.
1643 lines must be an iterable over the lines constituting the
1644 header and body of the email. to_addrs is a list of recipient
1645 addresses (can be needed even if lines already contains a
1646 "To:" field). It can be either a string (comma-separated list
1647 of email addresses) or a Python list of individual email
1648 addresses.
1652 raise NotImplementedError()
1655 class SendMailer(Mailer):
1656 """Send emails using 'sendmail -oi -t'."""
1658 SENDMAIL_CANDIDATES = [
1659 '/usr/sbin/sendmail',
1660 '/usr/lib/sendmail',
1663 @staticmethod
1664 def find_sendmail():
1665 for path in SendMailer.SENDMAIL_CANDIDATES:
1666 if os.access(path, os.X_OK):
1667 return path
1668 else:
1669 raise ConfigurationException(
1670 'No sendmail executable found. '
1671 'Try setting multimailhook.sendmailCommand.'
1674 def __init__(self, command=None, envelopesender=None):
1675 """Construct a SendMailer instance.
1677 command should be the command and arguments used to invoke
1678 sendmail, as a list of strings. If an envelopesender is
1679 provided, it will also be passed to the command, via '-f
1680 envelopesender'."""
1682 if command:
1683 self.command = command[:]
1684 else:
1685 self.command = [self.find_sendmail(), '-oi', '-t']
1687 if envelopesender:
1688 self.command.extend(['-f', envelopesender])
1690 def send(self, lines, to_addrs):
1691 try:
1692 p = subprocess.Popen(self.command, stdin=subprocess.PIPE)
1693 except OSError, e:
1694 sys.stderr.write(
1695 '*** Cannot execute command: %s\n' % ' '.join(self.command)
1696 + '*** %s\n' % str(e)
1697 + '*** Try setting multimailhook.mailer to "smtp"\n'
1698 '*** to send emails without using the sendmail command.\n'
1700 sys.exit(1)
1701 try:
1702 p.stdin.writelines(lines)
1703 except Exception, e:
1704 sys.stderr.write(
1705 '*** Error while generating commit email\n'
1706 '*** - mail sending aborted.\n'
1708 try:
1709 # subprocess.terminate() is not available in Python 2.4
1710 p.terminate()
1711 except AttributeError:
1712 pass
1713 raise e
1714 else:
1715 p.stdin.close()
1716 retcode = p.wait()
1717 if retcode:
1718 raise CommandError(self.command, retcode)
1721 class SMTPMailer(Mailer):
1722 """Send emails using Python's smtplib."""
1724 def __init__(self, envelopesender, smtpserver,
1725 smtpservertimeout=10.0, smtpserverdebuglevel=0,
1726 smtpencryption='none',
1727 smtpuser='', smtppass='',
1729 if not envelopesender:
1730 sys.stderr.write(
1731 'fatal: git_multimail: cannot use SMTPMailer without a sender address.\n'
1732 'please set either multimailhook.envelopeSender or user.email\n'
1734 sys.exit(1)
1735 if smtpencryption == 'ssl' and not (smtpuser and smtppass):
1736 raise ConfigurationException(
1737 'Cannot use SMTPMailer with security option ssl '
1738 'without options username and password.'
1740 self.envelopesender = envelopesender
1741 self.smtpserver = smtpserver
1742 self.smtpservertimeout = smtpservertimeout
1743 self.smtpserverdebuglevel = smtpserverdebuglevel
1744 self.security = smtpencryption
1745 self.username = smtpuser
1746 self.password = smtppass
1747 try:
1748 def call(klass, server, timeout):
1749 try:
1750 return klass(server, timeout=timeout)
1751 except TypeError:
1752 # Old Python versions do not have timeout= argument.
1753 return klass(server)
1754 if self.security == 'none':
1755 self.smtp = call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)
1756 elif self.security == 'ssl':
1757 self.smtp = call(smtplib.SMTP_SSL, self.smtpserver, timeout=self.smtpservertimeout)
1758 elif self.security == 'tls':
1759 if ':' not in self.smtpserver:
1760 self.smtpserver += ':587' # default port for TLS
1761 self.smtp = call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)
1762 self.smtp.ehlo()
1763 self.smtp.starttls()
1764 self.smtp.ehlo()
1765 else:
1766 sys.stdout.write('*** Error: Control reached an invalid option. ***')
1767 sys.exit(1)
1768 if self.smtpserverdebuglevel > 0:
1769 sys.stdout.write(
1770 "*** Setting debug on for SMTP server connection (%s) ***\n"
1771 % self.smtpserverdebuglevel)
1772 self.smtp.set_debuglevel(self.smtpserverdebuglevel)
1773 except Exception, e:
1774 sys.stderr.write(
1775 '*** Error establishing SMTP connection to %s ***\n'
1776 % self.smtpserver)
1777 sys.stderr.write('*** %s\n' % str(e))
1778 sys.exit(1)
1780 def __del__(self):
1781 if hasattr(self, 'smtp'):
1782 self.smtp.quit()
1784 def send(self, lines, to_addrs):
1785 try:
1786 if self.username or self.password:
1787 sys.stderr.write("*** Authenticating as %s ***\n" % self.username)
1788 self.smtp.login(self.username, self.password)
1789 msg = ''.join(lines)
1790 # turn comma-separated list into Python list if needed.
1791 if isinstance(to_addrs, basestring):
1792 to_addrs = [email for (name, email) in getaddresses([to_addrs])]
1793 self.smtp.sendmail(self.envelopesender, to_addrs, msg)
1794 except Exception, e:
1795 sys.stderr.write('*** Error sending email ***\n')
1796 sys.stderr.write('*** %s\n' % str(e))
1797 self.smtp.quit()
1798 sys.exit(1)
1801 class OutputMailer(Mailer):
1802 """Write emails to an output stream, bracketed by lines of '=' characters.
1804 This is intended for debugging purposes."""
1806 SEPARATOR = '=' * 75 + '\n'
1808 def __init__(self, f):
1809 self.f = f
1811 def send(self, lines, to_addrs):
1812 self.f.write(self.SEPARATOR)
1813 self.f.writelines(lines)
1814 self.f.write(self.SEPARATOR)
1817 def get_git_dir():
1818 """Determine GIT_DIR.
1820 Determine GIT_DIR either from the GIT_DIR environment variable or
1821 from the working directory, using Git's usual rules."""
1823 try:
1824 return read_git_output(['rev-parse', '--git-dir'])
1825 except CommandError:
1826 sys.stderr.write('fatal: git_multimail: not in a git directory\n')
1827 sys.exit(1)
1830 class Environment(object):
1831 """Describes the environment in which the push is occurring.
1833 An Environment object encapsulates information about the local
1834 environment. For example, it knows how to determine:
1836 * the name of the repository to which the push occurred
1838 * what user did the push
1840 * what users want to be informed about various types of changes.
1842 An Environment object is expected to have the following methods:
1844 get_repo_shortname()
1846 Return a short name for the repository, for display
1847 purposes.
1849 get_repo_path()
1851 Return the absolute path to the Git repository.
1853 get_emailprefix()
1855 Return a string that will be prefixed to every email's
1856 subject.
1858 get_pusher()
1860 Return the username of the person who pushed the changes.
1861 This value is used in the email body to indicate who
1862 pushed the change.
1864 get_pusher_email() (may return None)
1866 Return the email address of the person who pushed the
1867 changes. The value should be a single RFC 2822 email
1868 address as a string; e.g., "Joe User <user@example.com>"
1869 if available, otherwise "user@example.com". If set, the
1870 value is used as the Reply-To address for refchange
1871 emails. If it is impossible to determine the pusher's
1872 email, this attribute should be set to None (in which case
1873 no Reply-To header will be output).
1875 get_sender()
1877 Return the address to be used as the 'From' email address
1878 in the email envelope.
1880 get_fromaddr()
1882 Return the 'From' email address used in the email 'From:'
1883 headers. (May be a full RFC 2822 email address like 'Joe
1884 User <user@example.com>'.)
1886 get_administrator()
1888 Return the name and/or email of the repository
1889 administrator. This value is used in the footer as the
1890 person to whom requests to be removed from the
1891 notification list should be sent. Ideally, it should
1892 include a valid email address.
1894 get_reply_to_refchange()
1895 get_reply_to_commit()
1897 Return the address to use in the email "Reply-To" header,
1898 as a string. These can be an RFC 2822 email address, or
1899 None to omit the "Reply-To" header.
1900 get_reply_to_refchange() is used for refchange emails;
1901 get_reply_to_commit() is used for individual commit
1902 emails.
1904 They should also define the following attributes:
1906 announce_show_shortlog (bool)
1908 True iff announce emails should include a shortlog.
1910 refchange_showgraph (bool)
1912 True iff refchanges emails should include a detailed graph.
1914 refchange_showlog (bool)
1916 True iff refchanges emails should include a detailed log.
1918 diffopts (list of strings)
1920 The options that should be passed to 'git diff' for the
1921 summary email. The value should be a list of strings
1922 representing words to be passed to the command.
1924 graphopts (list of strings)
1926 Analogous to diffopts, but contains options passed to
1927 'git log --graph' when generating the detailed graph for
1928 a set of commits (see refchange_showgraph)
1930 logopts (list of strings)
1932 Analogous to diffopts, but contains options passed to
1933 'git log' when generating the detailed log for a set of
1934 commits (see refchange_showlog)
1936 commitlogopts (list of strings)
1938 The options that should be passed to 'git log' for each
1939 commit mail. The value should be a list of strings
1940 representing words to be passed to the command.
1942 quiet (bool)
1943 On success do not write to stderr
1945 stdout (bool)
1946 Write email to stdout rather than emailing. Useful for debugging
1948 combine_when_single_commit (bool)
1950 True if a combined email should be produced when a single
1951 new commit is pushed to a branch, False otherwise.
1955 REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$')
1957 def __init__(self, osenv=None):
1958 self.osenv = osenv or os.environ
1959 self.announce_show_shortlog = False
1960 self.maxcommitemails = 500
1961 self.diffopts = ['--stat', '--summary', '--find-copies-harder']
1962 self.graphopts = ['--oneline', '--decorate']
1963 self.logopts = []
1964 self.refchange_showgraph = False
1965 self.refchange_showlog = False
1966 self.commitlogopts = ['-C', '--stat', '-p', '--cc']
1967 self.quiet = False
1968 self.stdout = False
1969 self.combine_when_single_commit = True
1971 self.COMPUTED_KEYS = [
1972 'administrator',
1973 'charset',
1974 'emailprefix',
1975 'fromaddr',
1976 'pusher',
1977 'pusher_email',
1978 'repo_path',
1979 'repo_shortname',
1980 'sender',
1983 self._values = None
1985 def get_repo_shortname(self):
1986 """Use the last part of the repo path, with ".git" stripped off if present."""
1988 basename = os.path.basename(os.path.abspath(self.get_repo_path()))
1989 m = self.REPO_NAME_RE.match(basename)
1990 if m:
1991 return m.group('name')
1992 else:
1993 return basename
1995 def get_pusher(self):
1996 raise NotImplementedError()
1998 def get_pusher_email(self):
1999 return None
2001 def get_fromaddr(self):
2002 config = Config('user')
2003 fromname = config.get('name', default='')
2004 fromemail = config.get('email', default='')
2005 if fromemail:
2006 return formataddr([fromname, fromemail])
2007 return self.get_sender()
2009 def get_administrator(self):
2010 return 'the administrator of this repository'
2012 def get_emailprefix(self):
2013 return ''
2015 def get_repo_path(self):
2016 if read_git_output(['rev-parse', '--is-bare-repository']) == 'true':
2017 path = get_git_dir()
2018 else:
2019 path = read_git_output(['rev-parse', '--show-toplevel'])
2020 return os.path.abspath(path)
2022 def get_charset(self):
2023 return CHARSET
2025 def get_values(self):
2026 """Return a dictionary {keyword: expansion} for this Environment.
2028 This method is called by Change._compute_values(). The keys
2029 in the returned dictionary are available to be used in any of
2030 the templates. The dictionary is created by calling
2031 self.get_NAME() for each of the attributes named in
2032 COMPUTED_KEYS and recording those that do not return None.
2033 The return value is always a new dictionary."""
2035 if self._values is None:
2036 values = {}
2038 for key in self.COMPUTED_KEYS:
2039 value = getattr(self, 'get_%s' % (key,))()
2040 if value is not None:
2041 values[key] = value
2043 self._values = values
2045 return self._values.copy()
2047 def get_refchange_recipients(self, refchange):
2048 """Return the recipients for notifications about refchange.
2050 Return the list of email addresses to which notifications
2051 about the specified ReferenceChange should be sent."""
2053 raise NotImplementedError()
2055 def get_announce_recipients(self, annotated_tag_change):
2056 """Return the recipients for notifications about annotated_tag_change.
2058 Return the list of email addresses to which notifications
2059 about the specified AnnotatedTagChange should be sent."""
2061 raise NotImplementedError()
2063 def get_reply_to_refchange(self, refchange):
2064 return self.get_pusher_email()
2066 def get_revision_recipients(self, revision):
2067 """Return the recipients for messages about revision.
2069 Return the list of email addresses to which notifications
2070 about the specified Revision should be sent. This method
2071 could be overridden, for example, to take into account the
2072 contents of the revision when deciding whom to notify about
2073 it. For example, there could be a scheme for users to express
2074 interest in particular files or subdirectories, and only
2075 receive notification emails for revisions that affecting those
2076 files."""
2078 raise NotImplementedError()
2080 def get_reply_to_commit(self, revision):
2081 return revision.author
2083 def filter_body(self, lines):
2084 """Filter the lines intended for an email body.
2086 lines is an iterable over the lines that would go into the
2087 email body. Filter it (e.g., limit the number of lines, the
2088 line length, character set, etc.), returning another iterable.
2089 See FilterLinesEnvironmentMixin and MaxlinesEnvironmentMixin
2090 for classes implementing this functionality."""
2092 return lines
2094 def log_msg(self, msg):
2095 """Write the string msg on a log file or on stderr.
2097 Sends the text to stderr by default, override to change the behavior."""
2098 sys.stderr.write(msg)
2100 def log_warning(self, msg):
2101 """Write the string msg on a log file or on stderr.
2103 Sends the text to stderr by default, override to change the behavior."""
2104 sys.stderr.write(msg)
2106 def log_error(self, msg):
2107 """Write the string msg on a log file or on stderr.
2109 Sends the text to stderr by default, override to change the behavior."""
2110 sys.stderr.write(msg)
2113 class ConfigEnvironmentMixin(Environment):
2114 """A mixin that sets self.config to its constructor's config argument.
2116 This class's constructor consumes the "config" argument.
2118 Mixins that need to inspect the config should inherit from this
2119 class (1) to make sure that "config" is still in the constructor
2120 arguments with its own constructor runs and/or (2) to be sure that
2121 self.config is set after construction."""
2123 def __init__(self, config, **kw):
2124 super(ConfigEnvironmentMixin, self).__init__(**kw)
2125 self.config = config
2128 class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):
2129 """An Environment that reads most of its information from "git config"."""
2131 def __init__(self, config, **kw):
2132 super(ConfigOptionsEnvironmentMixin, self).__init__(
2133 config=config, **kw
2136 for var, cfg in (
2137 ('announce_show_shortlog', 'announceshortlog'),
2138 ('refchange_showgraph', 'refchangeShowGraph'),
2139 ('refchange_showlog', 'refchangeshowlog'),
2140 ('quiet', 'quiet'),
2141 ('stdout', 'stdout'),
2143 val = config.get_bool(cfg)
2144 if val is not None:
2145 setattr(self, var, val)
2147 maxcommitemails = config.get('maxcommitemails')
2148 if maxcommitemails is not None:
2149 try:
2150 self.maxcommitemails = int(maxcommitemails)
2151 except ValueError:
2152 self.log_warning(
2153 '*** Malformed value for multimailhook.maxCommitEmails: %s\n' % maxcommitemails
2154 + '*** Expected a number. Ignoring.\n'
2157 diffopts = config.get('diffopts')
2158 if diffopts is not None:
2159 self.diffopts = shlex.split(diffopts)
2161 graphopts = config.get('graphOpts')
2162 if graphopts is not None:
2163 self.graphopts = shlex.split(graphopts)
2165 logopts = config.get('logopts')
2166 if logopts is not None:
2167 self.logopts = shlex.split(logopts)
2169 commitlogopts = config.get('commitlogopts')
2170 if commitlogopts is not None:
2171 self.commitlogopts = shlex.split(commitlogopts)
2173 reply_to = config.get('replyTo')
2174 self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to)
2175 if (
2176 self.__reply_to_refchange is not None
2177 and self.__reply_to_refchange.lower() == 'author'
2179 raise ConfigurationException(
2180 '"author" is not an allowed setting for replyToRefchange'
2182 self.__reply_to_commit = config.get('replyToCommit', default=reply_to)
2184 combine = config.get_bool('combineWhenSingleCommit')
2185 if combine is not None:
2186 self.combine_when_single_commit = combine
2188 def get_administrator(self):
2189 return (
2190 self.config.get('administrator')
2191 or self.get_sender()
2192 or super(ConfigOptionsEnvironmentMixin, self).get_administrator()
2195 def get_repo_shortname(self):
2196 return (
2197 self.config.get('reponame')
2198 or super(ConfigOptionsEnvironmentMixin, self).get_repo_shortname()
2201 def get_emailprefix(self):
2202 emailprefix = self.config.get('emailprefix')
2203 if emailprefix is not None:
2204 emailprefix = emailprefix.strip()
2205 if emailprefix:
2206 return emailprefix + ' '
2207 else:
2208 return ''
2209 else:
2210 return '[%s] ' % (self.get_repo_shortname(),)
2212 def get_sender(self):
2213 return self.config.get('envelopesender')
2215 def get_fromaddr(self):
2216 fromaddr = self.config.get('from')
2217 if fromaddr:
2218 return fromaddr
2219 return super(ConfigOptionsEnvironmentMixin, self).get_fromaddr()
2221 def get_reply_to_refchange(self, refchange):
2222 if self.__reply_to_refchange is None:
2223 return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_refchange(refchange)
2224 elif self.__reply_to_refchange.lower() == 'pusher':
2225 return self.get_pusher_email()
2226 elif self.__reply_to_refchange.lower() == 'none':
2227 return None
2228 else:
2229 return self.__reply_to_refchange
2231 def get_reply_to_commit(self, revision):
2232 if self.__reply_to_commit is None:
2233 return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_commit(revision)
2234 elif self.__reply_to_commit.lower() == 'author':
2235 return revision.author
2236 elif self.__reply_to_commit.lower() == 'pusher':
2237 return self.get_pusher_email()
2238 elif self.__reply_to_commit.lower() == 'none':
2239 return None
2240 else:
2241 return self.__reply_to_commit
2243 def get_scancommitforcc(self):
2244 return self.config.get('scancommitforcc')
2247 class FilterLinesEnvironmentMixin(Environment):
2248 """Handle encoding and maximum line length of body lines.
2250 emailmaxlinelength (int or None)
2252 The maximum length of any single line in the email body.
2253 Longer lines are truncated at that length with ' [...]'
2254 appended.
2256 strict_utf8 (bool)
2258 If this field is set to True, then the email body text is
2259 expected to be UTF-8. Any invalid characters are
2260 converted to U+FFFD, the Unicode replacement character
2261 (encoded as UTF-8, of course).
2265 def __init__(self, strict_utf8=True, emailmaxlinelength=500, **kw):
2266 super(FilterLinesEnvironmentMixin, self).__init__(**kw)
2267 self.__strict_utf8 = strict_utf8
2268 self.__emailmaxlinelength = emailmaxlinelength
2270 def filter_body(self, lines):
2271 lines = super(FilterLinesEnvironmentMixin, self).filter_body(lines)
2272 if self.__strict_utf8:
2273 lines = (line.decode(ENCODING, 'replace') for line in lines)
2274 # Limit the line length in Unicode-space to avoid
2275 # splitting characters:
2276 if self.__emailmaxlinelength:
2277 lines = limit_linelength(lines, self.__emailmaxlinelength)
2278 lines = (line.encode(ENCODING, 'replace') for line in lines)
2279 elif self.__emailmaxlinelength:
2280 lines = limit_linelength(lines, self.__emailmaxlinelength)
2282 return lines
2285 class ConfigFilterLinesEnvironmentMixin(
2286 ConfigEnvironmentMixin,
2287 FilterLinesEnvironmentMixin,
2289 """Handle encoding and maximum line length based on config."""
2291 def __init__(self, config, **kw):
2292 strict_utf8 = config.get_bool('emailstrictutf8', default=None)
2293 if strict_utf8 is not None:
2294 kw['strict_utf8'] = strict_utf8
2296 emailmaxlinelength = config.get('emailmaxlinelength')
2297 if emailmaxlinelength is not None:
2298 kw['emailmaxlinelength'] = int(emailmaxlinelength)
2300 super(ConfigFilterLinesEnvironmentMixin, self).__init__(
2301 config=config, **kw
2305 class MaxlinesEnvironmentMixin(Environment):
2306 """Limit the email body to a specified number of lines."""
2308 def __init__(self, emailmaxlines, **kw):
2309 super(MaxlinesEnvironmentMixin, self).__init__(**kw)
2310 self.__emailmaxlines = emailmaxlines
2312 def filter_body(self, lines):
2313 lines = super(MaxlinesEnvironmentMixin, self).filter_body(lines)
2314 if self.__emailmaxlines:
2315 lines = limit_lines(lines, self.__emailmaxlines)
2316 return lines
2319 class ConfigMaxlinesEnvironmentMixin(
2320 ConfigEnvironmentMixin,
2321 MaxlinesEnvironmentMixin,
2323 """Limit the email body to the number of lines specified in config."""
2325 def __init__(self, config, **kw):
2326 emailmaxlines = int(config.get('emailmaxlines', default='0'))
2327 super(ConfigMaxlinesEnvironmentMixin, self).__init__(
2328 config=config,
2329 emailmaxlines=emailmaxlines,
2330 **kw
2334 class FQDNEnvironmentMixin(Environment):
2335 """A mixin that sets the host's FQDN to its constructor argument."""
2337 def __init__(self, fqdn, **kw):
2338 super(FQDNEnvironmentMixin, self).__init__(**kw)
2339 self.COMPUTED_KEYS += ['fqdn']
2340 self.__fqdn = fqdn
2342 def get_fqdn(self):
2343 """Return the fully-qualified domain name for this host.
2345 Return None if it is unavailable or unwanted."""
2347 return self.__fqdn
2350 class ConfigFQDNEnvironmentMixin(
2351 ConfigEnvironmentMixin,
2352 FQDNEnvironmentMixin,
2354 """Read the FQDN from the config."""
2356 def __init__(self, config, **kw):
2357 fqdn = config.get('fqdn')
2358 super(ConfigFQDNEnvironmentMixin, self).__init__(
2359 config=config,
2360 fqdn=fqdn,
2361 **kw
2365 class ComputeFQDNEnvironmentMixin(FQDNEnvironmentMixin):
2366 """Get the FQDN by calling socket.getfqdn()."""
2368 def __init__(self, **kw):
2369 super(ComputeFQDNEnvironmentMixin, self).__init__(
2370 fqdn=socket.getfqdn(),
2371 **kw
2375 class PusherDomainEnvironmentMixin(ConfigEnvironmentMixin):
2376 """Deduce pusher_email from pusher by appending an emaildomain."""
2378 def __init__(self, **kw):
2379 super(PusherDomainEnvironmentMixin, self).__init__(**kw)
2380 self.__emaildomain = self.config.get('emaildomain')
2382 def get_pusher_email(self):
2383 if self.__emaildomain:
2384 # Derive the pusher's full email address in the default way:
2385 return '%s@%s' % (self.get_pusher(), self.__emaildomain)
2386 else:
2387 return super(PusherDomainEnvironmentMixin, self).get_pusher_email()
2390 class StaticRecipientsEnvironmentMixin(Environment):
2391 """Set recipients statically based on constructor parameters."""
2393 def __init__(
2394 self,
2395 refchange_recipients, announce_recipients, revision_recipients, scancommitforcc,
2396 **kw
2398 super(StaticRecipientsEnvironmentMixin, self).__init__(**kw)
2400 # The recipients for various types of notification emails, as
2401 # RFC 2822 email addresses separated by commas (or the empty
2402 # string if no recipients are configured). Although there is
2403 # a mechanism to choose the recipient lists based on on the
2404 # actual *contents* of the change being reported, we only
2405 # choose based on the *type* of the change. Therefore we can
2406 # compute them once and for all:
2407 if not (refchange_recipients
2408 or announce_recipients
2409 or revision_recipients
2410 or scancommitforcc):
2411 raise ConfigurationException('No email recipients configured!')
2412 self.__refchange_recipients = refchange_recipients
2413 self.__announce_recipients = announce_recipients
2414 self.__revision_recipients = revision_recipients
2416 def get_refchange_recipients(self, refchange):
2417 return self.__refchange_recipients
2419 def get_announce_recipients(self, annotated_tag_change):
2420 return self.__announce_recipients
2422 def get_revision_recipients(self, revision):
2423 return self.__revision_recipients
2426 class ConfigRecipientsEnvironmentMixin(
2427 ConfigEnvironmentMixin,
2428 StaticRecipientsEnvironmentMixin
2430 """Determine recipients statically based on config."""
2432 def __init__(self, config, **kw):
2433 super(ConfigRecipientsEnvironmentMixin, self).__init__(
2434 config=config,
2435 refchange_recipients=self._get_recipients(
2436 config, 'refchangelist', 'mailinglist',
2438 announce_recipients=self._get_recipients(
2439 config, 'announcelist', 'refchangelist', 'mailinglist',
2441 revision_recipients=self._get_recipients(
2442 config, 'commitlist', 'mailinglist',
2444 scancommitforcc=config.get('scancommitforcc'),
2445 **kw
2448 def _get_recipients(self, config, *names):
2449 """Return the recipients for a particular type of message.
2451 Return the list of email addresses to which a particular type
2452 of notification email should be sent, by looking at the config
2453 value for "multimailhook.$name" for each of names. Use the
2454 value from the first name that is configured. The return
2455 value is a (possibly empty) string containing RFC 2822 email
2456 addresses separated by commas. If no configuration could be
2457 found, raise a ConfigurationException."""
2459 for name in names:
2460 retval = config.get_recipients(name)
2461 if retval is not None:
2462 return retval
2463 else:
2464 return ''
2467 class ProjectdescEnvironmentMixin(Environment):
2468 """Make a "projectdesc" value available for templates.
2470 By default, it is set to the first line of $GIT_DIR/description
2471 (if that file is present and appears to be set meaningfully)."""
2473 def __init__(self, **kw):
2474 super(ProjectdescEnvironmentMixin, self).__init__(**kw)
2475 self.COMPUTED_KEYS += ['projectdesc']
2477 def get_projectdesc(self):
2478 """Return a one-line descripition of the project."""
2480 git_dir = get_git_dir()
2481 try:
2482 projectdesc = open(os.path.join(git_dir, 'description')).readline().strip()
2483 if projectdesc and not projectdesc.startswith('Unnamed repository'):
2484 return projectdesc
2485 except IOError:
2486 pass
2488 return 'UNNAMED PROJECT'
2491 class GenericEnvironmentMixin(Environment):
2492 def get_pusher(self):
2493 return self.osenv.get('USER', self.osenv.get('USERNAME', 'unknown user'))
2496 class GenericEnvironment(
2497 ProjectdescEnvironmentMixin,
2498 ConfigMaxlinesEnvironmentMixin,
2499 ComputeFQDNEnvironmentMixin,
2500 ConfigFilterLinesEnvironmentMixin,
2501 ConfigRecipientsEnvironmentMixin,
2502 PusherDomainEnvironmentMixin,
2503 ConfigOptionsEnvironmentMixin,
2504 GenericEnvironmentMixin,
2505 Environment,
2507 pass
2510 class GitoliteEnvironmentMixin(Environment):
2511 def get_repo_shortname(self):
2512 # The gitolite environment variable $GL_REPO is a pretty good
2513 # repo_shortname (though it's probably not as good as a value
2514 # the user might have explicitly put in his config).
2515 return (
2516 self.osenv.get('GL_REPO', None)
2517 or super(GitoliteEnvironmentMixin, self).get_repo_shortname()
2520 def get_pusher(self):
2521 return self.osenv.get('GL_USER', 'unknown user')
2523 def get_fromaddr(self):
2524 GL_USER = self.osenv.get('GL_USER')
2525 if GL_USER is not None:
2526 # Find the path to gitolite.conf. Note that gitolite v3
2527 # did away with the GL_ADMINDIR and GL_CONF environment
2528 # variables (they are now hard-coded).
2529 GL_ADMINDIR = self.osenv.get(
2530 'GL_ADMINDIR',
2531 os.path.expanduser(os.path.join('~', '.gitolite')))
2532 GL_CONF = self.osenv.get(
2533 'GL_CONF',
2534 os.path.join(GL_ADMINDIR, 'conf', 'gitolite.conf'))
2535 if os.path.isfile(GL_CONF):
2536 f = open(GL_CONF, 'rU')
2537 try:
2538 in_user_emails_section = False
2539 re_template = r'^\s*#\s*{}\s*$'
2540 re_begin, re_user, re_end = (
2541 re.compile(re_template.format(x))
2542 for x in (
2543 r'BEGIN\s+USER\s+EMAILS',
2544 re.escape(GL_USER) + r'\s+(.*)',
2545 r'END\s+USER\s+EMAILS',
2547 for l in f:
2548 l = l.rstrip('\n')
2549 if not in_user_emails_section:
2550 if re_begin.match(l):
2551 in_user_emails_section = True
2552 continue
2553 if re_end.match(l):
2554 break
2555 m = re_user.match(l)
2556 if m:
2557 return m.group(1)
2558 finally:
2559 f.close()
2560 return super(GitoliteEnvironmentMixin, self).get_fromaddr()
2563 class IncrementalDateTime(object):
2564 """Simple wrapper to give incremental date/times.
2566 Each call will result in a date/time a second later than the
2567 previous call. This can be used to falsify email headers, to
2568 increase the likelihood that email clients sort the emails
2569 correctly."""
2571 def __init__(self):
2572 self.time = time.time()
2574 def next(self):
2575 formatted = formatdate(self.time, True)
2576 self.time += 1
2577 return formatted
2580 class GitoliteEnvironment(
2581 ProjectdescEnvironmentMixin,
2582 ConfigMaxlinesEnvironmentMixin,
2583 ComputeFQDNEnvironmentMixin,
2584 ConfigFilterLinesEnvironmentMixin,
2585 ConfigRecipientsEnvironmentMixin,
2586 PusherDomainEnvironmentMixin,
2587 ConfigOptionsEnvironmentMixin,
2588 GitoliteEnvironmentMixin,
2589 Environment,
2591 pass
2594 class Push(object):
2595 """Represent an entire push (i.e., a group of ReferenceChanges).
2597 It is easy to figure out what commits were added to a *branch* by
2598 a Reference change:
2600 git rev-list change.old..change.new
2602 or removed from a *branch*:
2604 git rev-list change.new..change.old
2606 But it is not quite so trivial to determine which entirely new
2607 commits were added to the *repository* by a push and which old
2608 commits were discarded by a push. A big part of the job of this
2609 class is to figure out these things, and to make sure that new
2610 commits are only detailed once even if they were added to multiple
2611 references.
2613 The first step is to determine the "other" references--those
2614 unaffected by the current push. They are computed by listing all
2615 references then removing any affected by this push. The results
2616 are stored in Push._other_ref_sha1s.
2618 The commits contained in the repository before this push were
2620 git rev-list other1 other2 other3 ... change1.old change2.old ...
2622 Where "changeN.old" is the old value of one of the references
2623 affected by this push.
2625 The commits contained in the repository after this push are
2627 git rev-list other1 other2 other3 ... change1.new change2.new ...
2629 The commits added by this push are the difference between these
2630 two sets, which can be written
2632 git rev-list \
2633 ^other1 ^other2 ... \
2634 ^change1.old ^change2.old ... \
2635 change1.new change2.new ...
2637 The commits removed by this push can be computed by
2639 git rev-list \
2640 ^other1 ^other2 ... \
2641 ^change1.new ^change2.new ... \
2642 change1.old change2.old ...
2644 The last point is that it is possible that other pushes are
2645 occurring simultaneously to this one, so reference values can
2646 change at any time. It is impossible to eliminate all race
2647 conditions, but we reduce the window of time during which problems
2648 can occur by translating reference names to SHA1s as soon as
2649 possible and working with SHA1s thereafter (because SHA1s are
2650 immutable)."""
2652 # A map {(changeclass, changetype): integer} specifying the order
2653 # that reference changes will be processed if multiple reference
2654 # changes are included in a single push. The order is significant
2655 # mostly because new commit notifications are threaded together
2656 # with the first reference change that includes the commit. The
2657 # following order thus causes commits to be grouped with branch
2658 # changes (as opposed to tag changes) if possible.
2659 SORT_ORDER = dict(
2660 (value, i) for (i, value) in enumerate([
2661 (BranchChange, 'update'),
2662 (BranchChange, 'create'),
2663 (AnnotatedTagChange, 'update'),
2664 (AnnotatedTagChange, 'create'),
2665 (NonAnnotatedTagChange, 'update'),
2666 (NonAnnotatedTagChange, 'create'),
2667 (BranchChange, 'delete'),
2668 (AnnotatedTagChange, 'delete'),
2669 (NonAnnotatedTagChange, 'delete'),
2670 (OtherReferenceChange, 'update'),
2671 (OtherReferenceChange, 'create'),
2672 (OtherReferenceChange, 'delete'),
2676 def __init__(self, changes, ignore_other_refs=False):
2677 self.changes = sorted(changes, key=self._sort_key)
2678 self.__other_ref_sha1s = None
2679 self.__cached_commits_spec = {}
2681 if ignore_other_refs:
2682 self.__other_ref_sha1s = set()
2684 @classmethod
2685 def _sort_key(klass, change):
2686 return (klass.SORT_ORDER[change.__class__, change.change_type], change.refname,)
2688 @property
2689 def _other_ref_sha1s(self):
2690 """The GitObjects referred to by references unaffected by this push.
2692 if self.__other_ref_sha1s is None:
2693 # The refnames being changed by this push:
2694 updated_refs = set(
2695 change.refname
2696 for change in self.changes
2699 # The SHA-1s of commits referred to by all references in this
2700 # repository *except* updated_refs:
2701 sha1s = set()
2702 fmt = (
2703 '%(objectname) %(objecttype) %(refname)\n'
2704 '%(*objectname) %(*objecttype) %(refname)'
2706 for line in read_git_lines(
2707 ['for-each-ref', '--format=%s' % (fmt,)]):
2708 (sha1, type, name) = line.split(' ', 2)
2709 if sha1 and type == 'commit' and name not in updated_refs:
2710 sha1s.add(sha1)
2712 self.__other_ref_sha1s = sha1s
2714 return self.__other_ref_sha1s
2716 def _get_commits_spec_incl(self, new_or_old, reference_change=None):
2717 """Get new or old SHA-1 from one or each of the changed refs.
2719 Return a list of SHA-1 commit identifier strings suitable as
2720 arguments to 'git rev-list' (or 'git log' or ...). The
2721 returned identifiers are either the old or new values from one
2722 or all of the changed references, depending on the values of
2723 new_or_old and reference_change.
2725 new_or_old is either the string 'new' or the string 'old'. If
2726 'new', the returned SHA-1 identifiers are the new values from
2727 each changed reference. If 'old', the SHA-1 identifiers are
2728 the old values from each changed reference.
2730 If reference_change is specified and not None, only the new or
2731 old reference from the specified reference is included in the
2732 return value.
2734 This function returns None if there are no matching revisions
2735 (e.g., because a branch was deleted and new_or_old is 'new').
2738 if not reference_change:
2739 incl_spec = sorted(
2740 getattr(change, new_or_old).sha1
2741 for change in self.changes
2742 if getattr(change, new_or_old)
2744 if not incl_spec:
2745 incl_spec = None
2746 elif not getattr(reference_change, new_or_old).commit_sha1:
2747 incl_spec = None
2748 else:
2749 incl_spec = [getattr(reference_change, new_or_old).commit_sha1]
2750 return incl_spec
2752 def _get_commits_spec_excl(self, new_or_old):
2753 """Get exclusion revisions for determining new or discarded commits.
2755 Return a list of strings suitable as arguments to 'git
2756 rev-list' (or 'git log' or ...) that will exclude all
2757 commits that, depending on the value of new_or_old, were
2758 either previously in the repository (useful for determining
2759 which commits are new to the repository) or currently in the
2760 repository (useful for determining which commits were
2761 discarded from the repository).
2763 new_or_old is either the string 'new' or the string 'old'. If
2764 'new', the commits to be excluded are those that were in the
2765 repository before the push. If 'old', the commits to be
2766 excluded are those that are currently in the repository. """
2768 old_or_new = {'old': 'new', 'new': 'old'}[new_or_old]
2769 excl_revs = self._other_ref_sha1s.union(
2770 getattr(change, old_or_new).sha1
2771 for change in self.changes
2772 if getattr(change, old_or_new).type in ['commit', 'tag']
2774 return ['^' + sha1 for sha1 in sorted(excl_revs)]
2776 def get_commits_spec(self, new_or_old, reference_change=None):
2777 """Get rev-list arguments for added or discarded commits.
2779 Return a list of strings suitable as arguments to 'git
2780 rev-list' (or 'git log' or ...) that select those commits
2781 that, depending on the value of new_or_old, are either new to
2782 the repository or were discarded from the repository.
2784 new_or_old is either the string 'new' or the string 'old'. If
2785 'new', the returned list is used to select commits that are
2786 new to the repository. If 'old', the returned value is used
2787 to select the commits that have been discarded from the
2788 repository.
2790 If reference_change is specified and not None, the new or
2791 discarded commits are limited to those that are reachable from
2792 the new or old value of the specified reference.
2794 This function returns None if there are no added (or discarded)
2795 revisions.
2797 key = (new_or_old, reference_change)
2798 if key not in self.__cached_commits_spec:
2799 ret = self._get_commits_spec_incl(new_or_old, reference_change)
2800 if ret is not None:
2801 ret.extend(self._get_commits_spec_excl(new_or_old))
2802 self.__cached_commits_spec[key] = ret
2803 return self.__cached_commits_spec[key]
2805 def get_new_commits(self, reference_change=None):
2806 """Return a list of commits added by this push.
2808 Return a list of the object names of commits that were added
2809 by the part of this push represented by reference_change. If
2810 reference_change is None, then return a list of *all* commits
2811 added by this push."""
2813 spec = self.get_commits_spec('new', reference_change)
2814 return git_rev_list(spec)
2816 def get_discarded_commits(self, reference_change):
2817 """Return a list of commits discarded by this push.
2819 Return a list of the object names of commits that were
2820 entirely discarded from the repository by the part of this
2821 push represented by reference_change."""
2823 spec = self.get_commits_spec('old', reference_change)
2824 return git_rev_list(spec)
2826 def send_emails(self, mailer, body_filter=None):
2827 """Use send all of the notification emails needed for this push.
2829 Use send all of the notification emails (including reference
2830 change emails and commit emails) needed for this push. Send
2831 the emails using mailer. If body_filter is not None, then use
2832 it to filter the lines that are intended for the email
2833 body."""
2835 # The sha1s of commits that were introduced by this push.
2836 # They will be removed from this set as they are processed, to
2837 # guarantee that one (and only one) email is generated for
2838 # each new commit.
2839 unhandled_sha1s = set(self.get_new_commits())
2840 send_date = IncrementalDateTime()
2841 for change in self.changes:
2842 sha1s = []
2843 for sha1 in reversed(list(self.get_new_commits(change))):
2844 if sha1 in unhandled_sha1s:
2845 sha1s.append(sha1)
2846 unhandled_sha1s.remove(sha1)
2848 # Check if we've got anyone to send to
2849 if not change.recipients:
2850 change.environment.log_warning(
2851 '*** no recipients configured so no email will be sent\n'
2852 '*** for %r update %s->%s\n'
2853 % (change.refname, change.old.sha1, change.new.sha1,)
2855 else:
2856 if not change.environment.quiet:
2857 change.environment.log_msg(
2858 'Sending notification emails to: %s\n' % (change.recipients,))
2859 extra_values = {'send_date': send_date.next()}
2861 rev = change.send_single_combined_email(sha1s)
2862 if rev:
2863 mailer.send(
2864 change.generate_combined_email(self, rev, body_filter, extra_values),
2865 rev.recipients,
2867 # This change is now fully handled; no need to handle
2868 # individual revisions any further.
2869 continue
2870 else:
2871 mailer.send(
2872 change.generate_email(self, body_filter, extra_values),
2873 change.recipients,
2876 max_emails = change.environment.maxcommitemails
2877 if max_emails and len(sha1s) > max_emails:
2878 change.environment.log_warning(
2879 '*** Too many new commits (%d), not sending commit emails.\n' % len(sha1s)
2880 + '*** Try setting multimailhook.maxCommitEmails to a greater value\n'
2881 + '*** Currently, multimailhook.maxCommitEmails=%d\n' % max_emails
2883 return
2885 for (num, sha1) in enumerate(sha1s):
2886 rev = Revision(change, GitObject(sha1), num=num + 1, tot=len(sha1s))
2887 if not rev.recipients and rev.cc_recipients:
2888 change.environment.log_msg('*** Replacing Cc: with To:\n')
2889 rev.recipients = rev.cc_recipients
2890 rev.cc_recipients = None
2891 if rev.recipients:
2892 extra_values = {'send_date': send_date.next()}
2893 mailer.send(
2894 rev.generate_email(self, body_filter, extra_values),
2895 rev.recipients,
2898 # Consistency check:
2899 if unhandled_sha1s:
2900 change.environment.log_error(
2901 'ERROR: No emails were sent for the following new commits:\n'
2902 ' %s\n'
2903 % ('\n '.join(sorted(unhandled_sha1s)),)
2907 def run_as_post_receive_hook(environment, mailer):
2908 changes = []
2909 for line in sys.stdin:
2910 (oldrev, newrev, refname) = line.strip().split(' ', 2)
2911 changes.append(
2912 ReferenceChange.create(environment, oldrev, newrev, refname)
2914 push = Push(changes)
2915 push.send_emails(mailer, body_filter=environment.filter_body)
2918 def run_as_update_hook(environment, mailer, refname, oldrev, newrev, force_send=False):
2919 changes = [
2920 ReferenceChange.create(
2921 environment,
2922 read_git_output(['rev-parse', '--verify', oldrev]),
2923 read_git_output(['rev-parse', '--verify', newrev]),
2924 refname,
2927 push = Push(changes, force_send)
2928 push.send_emails(mailer, body_filter=environment.filter_body)
2931 def choose_mailer(config, environment):
2932 mailer = config.get('mailer', default='sendmail')
2934 if mailer == 'smtp':
2935 smtpserver = config.get('smtpserver', default='localhost')
2936 smtpservertimeout = float(config.get('smtpservertimeout', default=10.0))
2937 smtpserverdebuglevel = int(config.get('smtpserverdebuglevel', default=0))
2938 smtpencryption = config.get('smtpencryption', default='none')
2939 smtpuser = config.get('smtpuser', default='')
2940 smtppass = config.get('smtppass', default='')
2941 mailer = SMTPMailer(
2942 envelopesender=(environment.get_sender() or environment.get_fromaddr()),
2943 smtpserver=smtpserver, smtpservertimeout=smtpservertimeout,
2944 smtpserverdebuglevel=smtpserverdebuglevel,
2945 smtpencryption=smtpencryption,
2946 smtpuser=smtpuser,
2947 smtppass=smtppass,
2949 elif mailer == 'sendmail':
2950 command = config.get('sendmailcommand')
2951 if command:
2952 command = shlex.split(command)
2953 mailer = SendMailer(command=command, envelopesender=environment.get_sender())
2954 else:
2955 environment.log_error(
2956 'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n' % mailer
2957 + 'please use one of "smtp" or "sendmail".\n'
2959 sys.exit(1)
2960 return mailer
2963 KNOWN_ENVIRONMENTS = {
2964 'generic': GenericEnvironmentMixin,
2965 'gitolite': GitoliteEnvironmentMixin,
2969 def choose_environment(config, osenv=None, env=None, recipients=None):
2970 if not osenv:
2971 osenv = os.environ
2973 environment_mixins = [
2974 ProjectdescEnvironmentMixin,
2975 ConfigMaxlinesEnvironmentMixin,
2976 ComputeFQDNEnvironmentMixin,
2977 ConfigFilterLinesEnvironmentMixin,
2978 PusherDomainEnvironmentMixin,
2979 ConfigOptionsEnvironmentMixin,
2981 environment_kw = {
2982 'osenv': osenv,
2983 'config': config,
2986 if not env:
2987 env = config.get('environment')
2989 if not env:
2990 if 'GL_USER' in osenv and 'GL_REPO' in osenv:
2991 env = 'gitolite'
2992 else:
2993 env = 'generic'
2995 environment_mixins.append(KNOWN_ENVIRONMENTS[env])
2997 if recipients:
2998 environment_mixins.insert(0, StaticRecipientsEnvironmentMixin)
2999 environment_kw['refchange_recipients'] = recipients
3000 environment_kw['announce_recipients'] = recipients
3001 environment_kw['revision_recipients'] = recipients
3002 environment_kw['scancommitforcc'] = config.get('scancommitforcc')
3003 else:
3004 environment_mixins.insert(0, ConfigRecipientsEnvironmentMixin)
3006 environment_klass = type(
3007 'EffectiveEnvironment',
3008 tuple(environment_mixins) + (Environment,),
3011 return environment_klass(**environment_kw)
3014 def main(args):
3015 parser = optparse.OptionParser(
3016 description=__doc__,
3017 usage='%prog [OPTIONS]\n or: %prog [OPTIONS] REFNAME OLDREV NEWREV',
3020 parser.add_option(
3021 '--environment', '--env', action='store', type='choice',
3022 choices=['generic', 'gitolite'], default=None,
3023 help=(
3024 'Choose type of environment is in use. Default is taken from '
3025 'multimailhook.environment if set; otherwise "generic".'
3028 parser.add_option(
3029 '--stdout', action='store_true', default=False,
3030 help='Output emails to stdout rather than sending them.',
3032 parser.add_option(
3033 '--recipients', action='store', default=None,
3034 help='Set list of email recipients for all types of emails.',
3036 parser.add_option(
3037 '--show-env', action='store_true', default=False,
3038 help=(
3039 'Write to stderr the values determined for the environment '
3040 '(intended for debugging purposes).'
3043 parser.add_option(
3044 '--force-send', action='store_true', default=False,
3045 help=(
3046 'Force sending refchange email when using as an update hook. '
3047 'This is useful to work around the unreliable new commits '
3048 'detection in this mode.'
3052 (options, args) = parser.parse_args(args)
3054 config = Config('multimailhook')
3056 try:
3057 environment = choose_environment(
3058 config, osenv=os.environ,
3059 env=options.environment,
3060 recipients=options.recipients,
3063 if options.show_env:
3064 sys.stderr.write('Environment values:\n')
3065 for (k, v) in sorted(environment.get_values().items()):
3066 sys.stderr.write(' %s : %r\n' % (k, v))
3067 sys.stderr.write('\n')
3069 if options.stdout or environment.stdout:
3070 mailer = OutputMailer(sys.stdout)
3071 else:
3072 mailer = choose_mailer(config, environment)
3074 # Dual mode: if arguments were specified on the command line, run
3075 # like an update hook; otherwise, run as a post-receive hook.
3076 if args:
3077 if len(args) != 3:
3078 parser.error('Need zero or three non-option arguments')
3079 (refname, oldrev, newrev) = args
3080 run_as_update_hook(environment, mailer, refname, oldrev, newrev, options.force_send)
3081 else:
3082 run_as_post_receive_hook(environment, mailer)
3083 except ConfigurationException, e:
3084 sys.exit(str(e))
3087 if __name__ == '__main__':
3088 main(sys.argv[1:])