Merge branch 'jk/prune-mtime' into maint
[git] / contrib / hooks / multimail / git_multimail.py
1 #! /usr/bin/env python2
2
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.
8 #
9 # This file is part of git-multimail.
10 #
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.
14 #
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.
19 #
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/>.
23
24 """Generate notification emails for pushes to a git repository.
25
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.
31
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.
36
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.
40
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.
44
45 See the accompanying README file for the complete documentation.
46
47 """
48
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
59
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
73
74
75 DEBUG = False
76
77 ZEROS = '0' * 40
78 LOGBEGIN = '- Log -----------------------------------------------------------------\n'
79 LOGEND = '-----------------------------------------------------------------------\n'
80
81 ADDR_HEADERS = set(['from', 'to', 'cc', 'bcc', 'reply-to', 'sender'])
82
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')
88
89
90 REF_CREATED_SUBJECT_TEMPLATE = (
91     '%(emailprefix)s%(refname_type)s %(short_refname)s created'
92     ' (now %(newrev_short)s)'
93     )
94 REF_UPDATED_SUBJECT_TEMPLATE = (
95     '%(emailprefix)s%(refname_type)s %(short_refname)s updated'
96     ' (%(oldrev_short)s -> %(newrev_short)s)'
97     )
98 REF_DELETED_SUBJECT_TEMPLATE = (
99     '%(emailprefix)s%(refname_type)s %(short_refname)s deleted'
100     ' (was %(oldrev_short)s)'
101     )
102
103 COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE = (
104     '%(emailprefix)s%(refname_type)s %(short_refname)s updated: %(oneline)s'
105     )
106
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
124 """
125
126 REFCHANGE_INTRO_TEMPLATE = """\
127 This is an automated email from the git hooks/post-receive script.
128
129 %(pusher)s pushed a change to %(refname_type)s %(short_refname)s
130 in repository %(repo_shortname)s.
131
132 """
133
134
135 FOOTER_TEMPLATE = """\
136
137 -- \n\
138 To stop receiving notification emails like this one, please contact
139 %(administrator)s.
140 """
141
142
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.
146
147  * -- * -- N   %(refname)s (%(newrev_short)s)
148             \\
149              O -- O -- O   (%(oldrev_short)s)
150
151 Any revisions marked "omits" are not gone; other references still
152 refer to them.  Any revisions marked "discards" are gone forever.
153 """
154
155
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:
162
163  * -- * -- B -- O -- O -- O   (%(oldrev_short)s)
164             \\
165              N -- N -- N   %(refname)s (%(newrev_short)s)
166
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.
170
171 Any revisions marked "omits" are not gone; other references still
172 refer to them.  Any revisions marked "discards" are gone forever.
173 """
174
175
176 NO_NEW_REVISIONS_TEMPLATE = """\
177 No new revisions were added by this update.
178 """
179
180
181 DISCARDED_REVISIONS_TEMPLATE = """\
182 This change permanently discards the following revisions:
183 """
184
185
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.
190 """
191
192
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.
198
199 """
200
201
202 TAG_CREATED_TEMPLATE = """\
203         at  %(newrev_short)-9s (%(newrev_type)s)
204 """
205
206
207 TAG_UPDATED_TEMPLATE = """\
208 *** WARNING: tag %(short_refname)s was modified! ***
209
210       from  %(oldrev_short)-9s (%(oldrev_type)s)
211         to  %(newrev_short)-9s (%(newrev_type)s)
212 """
213
214
215 TAG_DELETED_TEMPLATE = """\
216 *** WARNING: tag %(short_refname)s was deleted! ***
217
218 """
219
220
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
225 """
226
227
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.
232 """
233
234
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
253 """
254
255 REVISION_INTRO_TEMPLATE = """\
256 This is an automated email from the git hooks/post-receive script.
257
258 %(pusher)s pushed a commit to %(refname_type)s %(short_refname)s
259 in repository %(repo_shortname)s.
260
261 """
262
263
264 REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE
265
266
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
286 """
287
288 COMBINED_INTRO_TEMPLATE = """\
289 This is an automated email from the git hooks/post-receive script.
290
291 %(pusher)s pushed a commit to %(refname_type)s %(short_refname)s
292 in repository %(repo_shortname)s.
293
294 """
295
296 COMBINED_FOOTER_TEMPLATE = FOOTER_TEMPLATE
297
298
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,)
306             )
307
308
309 class ConfigurationException(Exception):
310     pass
311
312
313 # The "git" program (this could be changed to include a full path):
314 GIT_EXECUTABLE = 'git'
315
316
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
322
323
324 def choose_git_command():
325     """Decide how to invoke git, and record the choice in GIT_CMD."""
326
327     global GIT_CMD
328
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]
341
342
343 def read_git_output(args, input=None, keepends=False, **kw):
344     """Read the output of a Git command."""
345
346     if GIT_CMD is None:
347         choose_git_command()
348
349     return read_output(GIT_CMD + args, input=input, keepends=keepends, **kw)
350
351
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
359         )
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
367
368
369 def read_git_lines(args, keepends=False, **kw):
370     """Return the lines output by Git command.
371
372     Return as single lines, with newlines stripped off."""
373
374     return read_git_output(args, keepends=True, **kw).splitlines(keepends)
375
376
377 def git_rev_list_ish(cmd, spec, args=None, **kw):
378     """Common functionality for invoking a 'git rev-list'-like command.
379
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.
387
388     Return the output of the Git command in the form of a list, one
389     entry per output line.
390     """
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)
398
399
400 def git_rev_list(spec, **kw):
401     """Run 'git rev-list' with the given list of revision arguments.
402
403     See git_rev_list_ish() for parameter and return value
404     documentation.
405     """
406     return git_rev_list_ish('rev-list', spec, **kw)
407
408
409 def git_log(spec, **kw):
410     """Run 'git log' with the given list of revision arguments.
411
412     See git_rev_list_ish() for parameter and return value
413     documentation.
414     """
415     return git_rev_list_ish('log', spec, **kw)
416
417
418 def header_encode(text, header_name=None):
419     """Encode and line-wrap the value of an email header field."""
420
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()
428
429
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."""
433
434     return Header(
435         ', '.join(
436             formataddr((header_encode(name), emailaddr))
437             for name, emailaddr in getaddresses([text])
438             ),
439         header_name=header_name
440         ).encode()
441
442
443 class Config(object):
444     def __init__(self, section, git_config=None):
445         """Represent a section of the git configuration.
446
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."""
451
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
458
459     @staticmethod
460     def _split(s):
461         """Split NUL-terminated values."""
462
463         words = s.split('\0')
464         assert words[-1] == ''
465         return words[:-1]
466
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,
472                 ))
473             assert len(values) == 1
474             return values[0]
475         except CommandError:
476             return default
477
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,
483                 )
484         except CommandError:
485             return default
486         return value == 'true'
487
488     def get_all(self, name, default=None):
489         """Read a (possibly multivalued) setting from the configuration.
490
491         Return the result as a list of values, or default if the name
492         is unset."""
493
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,
498                 ))
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
506
507     def get_recipients(self, name, default=None):
508         """Read a recipients list from the configuration.
509
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."""
513
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)
518
519     def set(self, name, value):
520         read_git_output(
521             ['config', '%s.%s' % (self.section, name), value],
522             env=self.env,
523             )
524
525     def add(self, name, value):
526         read_git_output(
527             ['config', '--add', '%s.%s' % (self.section, name), value],
528             env=self.env,
529             )
530
531     def __contains__(self, name):
532         return self.get_all(name, default=None) is not None
533
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
538
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,
544                 )
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
551
552     def set_recipients(self, name, value):
553         self.unset_all(name)
554         for pair in getaddresses([value]):
555             self.add(name, formataddr(pair))
556
557
558 def generate_summaries(*log_args):
559     """Generate a brief summary for each revision requested.
560
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)."""
565
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))
571
572
573 def limit_lines(lines, max_lines):
574     for (index, line) in enumerate(lines):
575         if index < max_lines:
576             yield line
577
578     if index >= max_lines:
579         yield '... %d lines suppressed ...\n' % (index + 1 - max_lines,)
580
581
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
588
589
590 class CommitSet(object):
591     """A (constant) set of object names.
592
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."""
596
597     def __init__(self, names):
598         self._names = sorted(names)
599
600     def __len__(self):
601         return len(self._names)
602
603     def __contains__(self, sha1_abbrev):
604         """Return True iff this set contains sha1_abbrev (which might be abbreviated)."""
605
606         i = bisect.bisect_left(self._names, sha1_abbrev)
607         return i < len(self) and self._names[i].startswith(sha1_abbrev)
608
609
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])
617
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,)]
624                         )
625                 except CommandError:
626                     # Cannot deref tag to determine commit_sha1
627                     self.commit_sha1 = None
628             else:
629                 self.commit_sha1 = None
630
631         self.short = read_git_output(['rev-parse', '--short', sha1])
632
633     def get_summary(self):
634         """Return (sha1_short, subject) for this commit."""
635
636         if not self.sha1:
637             raise ValueError('Empty commit has no summary')
638
639         return iter(generate_summaries('--no-walk', self.sha1)).next()
640
641     def __eq__(self, other):
642         return isinstance(other, GitObject) and self.sha1 == other.sha1
643
644     def __hash__(self):
645         return hash(self.sha1)
646
647     def __nonzero__(self):
648         return bool(self.sha1)
649
650     def __str__(self):
651         return self.sha1 or ZEROS
652
653
654 class Change(object):
655     """A Change that has been made to the Git repository.
656
657     Abstract class from which both Revisions and ReferenceChanges are
658     derived.  A Change knows how to generate a notification email
659     describing itself."""
660
661     def __init__(self, environment):
662         self.environment = environment
663         self._values = None
664
665     def _compute_values(self):
666         """Return a dictionary {keyword: expansion} for this Change.
667
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."""
672
673         return self.environment.get_values()
674
675     def get_values(self, **extra_values):
676         """Return a dictionary {keyword: expansion} for this Change.
677
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."""
683
684         if self._values is None:
685             self._values = self._compute_values()
686
687         values = self._values.copy()
688         if extra_values:
689             values.update(extra_values)
690         return values
691
692     def expand(self, template, **extra_values):
693         """Expand template.
694
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."""
699
700         return template % self.get_values(**extra_values)
701
702     def expand_lines(self, template, **extra_values):
703         """Break template into lines and expand each line."""
704
705         values = self.get_values(**extra_values)
706         for line in template.splitlines(True):
707             yield line % values
708
709     def expand_header_lines(self, template, **extra_values):
710         """Break template into lines and expand each line as an RFC 2822 header.
711
712         Encode values and split up lines that are too long.  Silently
713         skip lines that contain references to unknown variables."""
714
715         values = self.get_values(**extra_values)
716         for line in template.splitlines():
717             (name, value) = line.split(':', 1)
718
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,)
727                         )
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
735
736     def generate_email_header(self):
737         """Generate the RFC 2822 email headers for this Change, a line at a time.
738
739         The output should not include the trailing blank line."""
740
741         raise NotImplementedError()
742
743     def generate_email_intro(self):
744         """Generate the email intro for this Change, a line at a time.
745
746         The output will be used as the standard boilerplate at the top
747         of the email body."""
748
749         raise NotImplementedError()
750
751     def generate_email_body(self):
752         """Generate the main part of the email body, a line at a time.
753
754         The text in the body might be truncated after a specified
755         number of lines (see multimailhook.emailmaxlines)."""
756
757         raise NotImplementedError()
758
759     def generate_email_footer(self):
760         """Generate the footer of the email, a line at a time.
761
762         The footer is always included, irrespective of
763         multimailhook.emailmaxlines."""
764
765         raise NotImplementedError()
766
767     def generate_email(self, push, body_filter=None, extra_header_values={}):
768         """Generate an email describing this change.
769
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.
774
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()"""
778
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
784
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
790
791         for line in self.generate_email_footer():
792             yield line
793
794
795 class Revision(Change):
796     """A Change consisting of a single git commit."""
797
798     CC_RE = re.compile(r'^\s*C[Cc]:\s*(?P<to>[^#]+@[^\s#]*)\s*(#.*)?$')
799
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)
810
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))
817
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'))
826
827         return cc_recipients
828
829     def _compute_values(self):
830         values = Change._compute_values(self)
831
832         oneline = read_git_output(
833             ['log', '--format=%s', '--no-walk', self.rev.sha1]
834             )
835
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
850
851         reply_to = self.environment.get_reply_to_commit(self)
852         if reply_to:
853             values['reply_to'] = reply_to
854
855         return values
856
857     def generate_email_header(self, **extra_values):
858         for line in self.expand_header_lines(
859                 REVISION_HEADER_TEMPLATE, **extra_values
860                 ):
861             yield line
862
863     def generate_email_intro(self):
864         for line in self.expand_lines(REVISION_INTRO_TEMPLATE):
865             yield line
866
867     def generate_email_body(self, push):
868         """Show this revision."""
869
870         return read_git_lines(
871             ['log'] + self.environment.commitlogopts + ['-1', self.rev.sha1],
872             keepends=True,
873             )
874
875     def generate_email_footer(self):
876         return self.expand_lines(REVISION_FOOTER_TEMPLATE)
877
878
879 class ReferenceChange(Change):
880     """A Change to a Git reference.
881
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.
887
888     ReferenceChange objects are usually created using the static
889     create() method, which has the logic to decide which derived class
890     to instantiate."""
891
892     REF_RE = re.compile(r'^refs\/(?P<area>[^\/]+)\/(?P<shortname>.*)$')
893
894     @staticmethod
895     def create(environment, oldrev, newrev, refname):
896         """Return a ReferenceChange object representing the change.
897
898         Return an object that represents the type of change that is being
899         made. oldrev and newrev should be SHA1s or ZEROS."""
900
901         old = GitObject(oldrev)
902         new = GitObject(newrev)
903         rev = new or old
904
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
918
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,)
935                     )
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,)
943                     )
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,)
951                 )
952             klass = OtherReferenceChange
953
954         return klass(
955             environment,
956             refname=refname, short_refname=short_refname,
957             old=old, new=new, rev=rev,
958             )
959
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
979
980         self.header_template = REFCHANGE_HEADER_TEMPLATE
981         self.intro_template = REFCHANGE_INTRO_TEMPLATE
982         self.footer_template = FOOTER_TEMPLATE
983
984     def _compute_values(self):
985         values = Change._compute_values(self)
986
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
997
998         if self.old:
999             values['oldrev_type'] = self.old.type
1000         if self.new:
1001             values['newrev_type'] = self.new.type
1002
1003         reply_to = self.environment.get_reply_to_refchange(self)
1004         if reply_to:
1005             values['reply_to'] = reply_to
1006
1007         return values
1008
1009     def send_single_combined_email(self, known_added_sha1s):
1010         """Determine if a combined refchange/revision email should be sent
1011
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.
1016
1017         This method is overridden in BranchChange."""
1018
1019         return None
1020
1021     def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):
1022         """Generate an email describing this change AND specified revision.
1023
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.
1028
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()
1032
1033         This method is overridden in BranchChange."""
1034
1035         raise NotImplementedError
1036
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)
1044
1045     def generate_email_header(self, **extra_values):
1046         if 'subject' not in extra_values:
1047             extra_values['subject'] = self.get_subject()
1048
1049         for line in self.expand_header_lines(
1050                 self.header_template, **extra_values
1051                 ):
1052             yield line
1053
1054     def generate_email_intro(self):
1055         for line in self.expand_lines(self.intro_template):
1056             yield line
1057
1058     def generate_email_body(self, push):
1059         """Call the appropriate body-generation routine.
1060
1061         Call one of generate_create_summary() /
1062         generate_update_summary() / generate_delete_summary()."""
1063
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
1071
1072         for line in self.generate_revision_change_summary(push):
1073             yield line
1074
1075     def generate_email_footer(self):
1076         return self.expand_lines(self.footer_template)
1077
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'
1093
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,
1104                     ):
1105                 yield line
1106
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
1114
1115     def generate_revision_change_summary(self, push):
1116         """Generate a summary of the revisions added/removed by this change."""
1117
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)
1129                 ]
1130
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,
1138                         )
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
1146
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.
1152
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,)
1161                 ))
1162
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,)
1168                 ))
1169
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)
1175
1176             if discards:
1177                 discarded_commits = CommitSet(push.get_discarded_commits(self))
1178             else:
1179                 discarded_commits = CommitSet([])
1180
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,
1190                         )
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,
1199                         )
1200                 yield '\n'
1201                 for line in self.expand_lines(NON_FF_TEMPLATE):
1202                     yield line
1203
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,
1213                         )
1214                 yield '\n'
1215                 for line in self.expand_lines(REWIND_ONLY_TEMPLATE):
1216                     yield line
1217
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,
1223                     )
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,
1232                         )
1233
1234             yield '\n'
1235
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
1245
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,
1260                     ):
1261                 yield line
1262
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.
1266
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)
1272                 ]
1273
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,
1282                         )
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
1288
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
1292
1293     def generate_create_summary(self, push):
1294         """Called for the creation of a reference."""
1295
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,
1301             )
1302         yield '\n'
1303
1304     def generate_update_summary(self, push):
1305         """Called for the change of a pre-existing branch."""
1306
1307         return iter([])
1308
1309     def generate_delete_summary(self, push):
1310         """Called for the deletion of any type of reference."""
1311
1312         (sha1, subject) = self.old.get_summary()
1313         yield self.expand(
1314             BRIEF_SUMMARY_TEMPLATE, action='was',
1315             rev_short=sha1, text=subject,
1316             )
1317         yield '\n'
1318
1319
1320 class BranchChange(ReferenceChange):
1321     refname_type = 'branch'
1322
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,
1328             )
1329         self.recipients = environment.get_refchange_recipients(self)
1330         self._single_revision = None
1331
1332     def send_single_combined_email(self, known_added_sha1s):
1333         if not self.environment.combine_when_single_commit:
1334             return None
1335
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.
1346         #
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
1357
1358             if read_git_lines(
1359                     ['merge-base', self.old.sha1, self.new.sha1]
1360                     ) != [self.old.sha1]:
1361                 return None
1362
1363             # Check if this update introduced exactly one non-merge
1364             # commit:
1365
1366             def split_line(line):
1367                 """Split line into (sha1, [parent,...])."""
1368
1369                 words = line.split()
1370                 return (words[0], words[1:])
1371
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(
1377                     [
1378                         'log', '-3', '--format=%H %P',
1379                         '%s..%s' % (self.old.sha1, self.new.sha1),
1380                         ]
1381                     )
1382                 ]
1383
1384             if not new_commits:
1385                 return None
1386
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]
1394
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
1403                     ):
1404                 return None
1405
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
1411
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
1417
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
1426
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)
1433
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
1440
1441     def generate_email_body(self, push):
1442         '''Call the appropriate body generation routine.
1443
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.'''
1447
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
1453
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,)
1460             ))
1461
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,
1467                 )
1468
1469         yield self._single_revision.rev.short + " is described below\n"
1470         yield '\n'
1471
1472         for line in self._single_revision.generate_email_body(push):
1473             yield line
1474
1475
1476 class AnnotatedTagChange(ReferenceChange):
1477     refname_type = 'annotated tag'
1478
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,
1484             )
1485         self.recipients = environment.get_announce_recipients(self)
1486         self.show_shortlog = environment.announce_show_shortlog
1487
1488     ANNOTATED_TAG_FORMAT = (
1489         '%(*objectname)\n'
1490         '%(*objecttype)\n'
1491         '%(taggername)\n'
1492         '%(taggerdate)'
1493         )
1494
1495     def describe_tag(self, push):
1496         """Describe the new value of an annotated tag."""
1497
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],
1502             )
1503
1504         yield self.expand(
1505             BRIEF_SUMMARY_TEMPLATE, action='tagging',
1506             rev_short=tagobject, text='(%s)' % (tagtype,),
1507             )
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]),)
1521
1522         yield ' tagged by  %s\n' % (tagger,)
1523         yield '        on  %s\n' % (tagged,)
1524         yield '\n'
1525
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
1535
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,
1545                     )
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,
1552                     )
1553             for line in read_git_lines(['shortlog'], input=revlist, keepends=True):
1554                 yield line
1555
1556         yield LOGEND
1557         yield '\n'
1558
1559     def generate_create_summary(self, push):
1560         """Called for the creation of an annotated tag."""
1561
1562         for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1563             yield line
1564
1565         for line in self.describe_tag(push):
1566             yield line
1567
1568     def generate_update_summary(self, push):
1569         """Called for the update of an annotated tag.
1570
1571         This is probably a rare event and may not even be allowed."""
1572
1573         for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1574             yield line
1575
1576         for line in self.describe_tag(push):
1577             yield line
1578
1579     def generate_delete_summary(self, push):
1580         """Called when a non-annotated reference is updated."""
1581
1582         for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1583             yield line
1584
1585         yield self.expand('   tag was  %(oldrev_short)s\n')
1586         yield '\n'
1587
1588
1589 class NonAnnotatedTagChange(ReferenceChange):
1590     refname_type = 'tag'
1591
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,
1597             )
1598         self.recipients = environment.get_refchange_recipients(self)
1599
1600     def generate_create_summary(self, push):
1601         """Called for the creation of an annotated tag."""
1602
1603         for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1604             yield line
1605
1606     def generate_update_summary(self, push):
1607         """Called when a non-annotated reference is updated."""
1608
1609         for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1610             yield line
1611
1612     def generate_delete_summary(self, push):
1613         """Called when a non-annotated reference is updated."""
1614
1615         for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1616             yield line
1617
1618         for line in ReferenceChange.generate_delete_summary(self, push):
1619             yield line
1620
1621
1622 class OtherReferenceChange(ReferenceChange):
1623     refname_type = 'reference'
1624
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,
1633             )
1634         self.recipients = environment.get_refchange_recipients(self)
1635
1636
1637 class Mailer(object):
1638     """An object that can send emails."""
1639
1640     def send(self, lines, to_addrs):
1641         """Send an email consisting of lines.
1642
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.
1649
1650         """
1651
1652         raise NotImplementedError()
1653
1654
1655 class SendMailer(Mailer):
1656     """Send emails using 'sendmail -oi -t'."""
1657
1658     SENDMAIL_CANDIDATES = [
1659         '/usr/sbin/sendmail',
1660         '/usr/lib/sendmail',
1661         ]
1662
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.'
1672                 )
1673
1674     def __init__(self, command=None, envelopesender=None):
1675         """Construct a SendMailer instance.
1676
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'."""
1681
1682         if command:
1683             self.command = command[:]
1684         else:
1685             self.command = [self.find_sendmail(), '-oi', '-t']
1686
1687         if envelopesender:
1688             self.command.extend(['-f', envelopesender])
1689
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'
1699                 )
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'
1707                 )
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)
1719
1720
1721 class SMTPMailer(Mailer):
1722     """Send emails using Python's smtplib."""
1723
1724     def __init__(self, envelopesender, smtpserver,
1725                  smtpservertimeout=10.0, smtpserverdebuglevel=0,
1726                  smtpencryption='none',
1727                  smtpuser='', smtppass='',
1728                  ):
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'
1733                 )
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.'
1739                 )
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)
1779
1780     def __del__(self):
1781         if hasattr(self, 'smtp'):
1782             self.smtp.quit()
1783
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)
1799
1800
1801 class OutputMailer(Mailer):
1802     """Write emails to an output stream, bracketed by lines of '=' characters.
1803
1804     This is intended for debugging purposes."""
1805
1806     SEPARATOR = '=' * 75 + '\n'
1807
1808     def __init__(self, f):
1809         self.f = f
1810
1811     def send(self, lines, to_addrs):
1812         self.f.write(self.SEPARATOR)
1813         self.f.writelines(lines)
1814         self.f.write(self.SEPARATOR)
1815
1816
1817 def get_git_dir():
1818     """Determine GIT_DIR.
1819
1820     Determine GIT_DIR either from the GIT_DIR environment variable or
1821     from the working directory, using Git's usual rules."""
1822
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)
1828
1829
1830 class Environment(object):
1831     """Describes the environment in which the push is occurring.
1832
1833     An Environment object encapsulates information about the local
1834     environment.  For example, it knows how to determine:
1835
1836     * the name of the repository to which the push occurred
1837
1838     * what user did the push
1839
1840     * what users want to be informed about various types of changes.
1841
1842     An Environment object is expected to have the following methods:
1843
1844         get_repo_shortname()
1845
1846             Return a short name for the repository, for display
1847             purposes.
1848
1849         get_repo_path()
1850
1851             Return the absolute path to the Git repository.
1852
1853         get_emailprefix()
1854
1855             Return a string that will be prefixed to every email's
1856             subject.
1857
1858         get_pusher()
1859
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.
1863
1864         get_pusher_email() (may return None)
1865
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).
1874
1875         get_sender()
1876
1877             Return the address to be used as the 'From' email address
1878             in the email envelope.
1879
1880         get_fromaddr()
1881
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>'.)
1885
1886         get_administrator()
1887
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.
1893
1894         get_reply_to_refchange()
1895         get_reply_to_commit()
1896
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.
1903
1904     They should also define the following attributes:
1905
1906         announce_show_shortlog (bool)
1907
1908             True iff announce emails should include a shortlog.
1909
1910         refchange_showgraph (bool)
1911
1912             True iff refchanges emails should include a detailed graph.
1913
1914         refchange_showlog (bool)
1915
1916             True iff refchanges emails should include a detailed log.
1917
1918         diffopts (list of strings)
1919
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.
1923
1924         graphopts (list of strings)
1925
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)
1929
1930         logopts (list of strings)
1931
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)
1935
1936         commitlogopts (list of strings)
1937
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.
1941
1942         quiet (bool)
1943             On success do not write to stderr
1944
1945         stdout (bool)
1946             Write email to stdout rather than emailing. Useful for debugging
1947
1948         combine_when_single_commit (bool)
1949
1950             True if a combined email should be produced when a single
1951             new commit is pushed to a branch, False otherwise.
1952
1953     """
1954
1955     REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$')
1956
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
1970
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',
1981             ]
1982
1983         self._values = None
1984
1985     def get_repo_shortname(self):
1986         """Use the last part of the repo path, with ".git" stripped off if present."""
1987
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
1994
1995     def get_pusher(self):
1996         raise NotImplementedError()
1997
1998     def get_pusher_email(self):
1999         return None
2000
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()
2008
2009     def get_administrator(self):
2010         return 'the administrator of this repository'
2011
2012     def get_emailprefix(self):
2013         return ''
2014
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)
2021
2022     def get_charset(self):
2023         return CHARSET
2024
2025     def get_values(self):
2026         """Return a dictionary {keyword: expansion} for this Environment.
2027
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."""
2034
2035         if self._values is None:
2036             values = {}
2037
2038             for key in self.COMPUTED_KEYS:
2039                 value = getattr(self, 'get_%s' % (key,))()
2040                 if value is not None:
2041                     values[key] = value
2042
2043             self._values = values
2044
2045         return self._values.copy()
2046
2047     def get_refchange_recipients(self, refchange):
2048         """Return the recipients for notifications about refchange.
2049
2050         Return the list of email addresses to which notifications
2051         about the specified ReferenceChange should be sent."""
2052
2053         raise NotImplementedError()
2054
2055     def get_announce_recipients(self, annotated_tag_change):
2056         """Return the recipients for notifications about annotated_tag_change.
2057
2058         Return the list of email addresses to which notifications
2059         about the specified AnnotatedTagChange should be sent."""
2060
2061         raise NotImplementedError()
2062
2063     def get_reply_to_refchange(self, refchange):
2064         return self.get_pusher_email()
2065
2066     def get_revision_recipients(self, revision):
2067         """Return the recipients for messages about revision.
2068
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."""
2077
2078         raise NotImplementedError()
2079
2080     def get_reply_to_commit(self, revision):
2081         return revision.author
2082
2083     def filter_body(self, lines):
2084         """Filter the lines intended for an email body.
2085
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."""
2091
2092         return lines
2093
2094     def log_msg(self, msg):
2095         """Write the string msg on a log file or on stderr.
2096
2097         Sends the text to stderr by default, override to change the behavior."""
2098         sys.stderr.write(msg)
2099
2100     def log_warning(self, msg):
2101         """Write the string msg on a log file or on stderr.
2102
2103         Sends the text to stderr by default, override to change the behavior."""
2104         sys.stderr.write(msg)
2105
2106     def log_error(self, msg):
2107         """Write the string msg on a log file or on stderr.
2108
2109         Sends the text to stderr by default, override to change the behavior."""
2110         sys.stderr.write(msg)
2111
2112
2113 class ConfigEnvironmentMixin(Environment):
2114     """A mixin that sets self.config to its constructor's config argument.
2115
2116     This class's constructor consumes the "config" argument.
2117
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."""
2122
2123     def __init__(self, config, **kw):
2124         super(ConfigEnvironmentMixin, self).__init__(**kw)
2125         self.config = config
2126
2127
2128 class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):
2129     """An Environment that reads most of its information from "git config"."""
2130
2131     def __init__(self, config, **kw):
2132         super(ConfigOptionsEnvironmentMixin, self).__init__(
2133             config=config, **kw
2134             )
2135
2136         for var, cfg in (
2137                 ('announce_show_shortlog', 'announceshortlog'),
2138                 ('refchange_showgraph', 'refchangeShowGraph'),
2139                 ('refchange_showlog', 'refchangeshowlog'),
2140                 ('quiet', 'quiet'),
2141                 ('stdout', 'stdout'),
2142                 ):
2143             val = config.get_bool(cfg)
2144             if val is not None:
2145                 setattr(self, var, val)
2146
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'
2155                     )
2156
2157         diffopts = config.get('diffopts')
2158         if diffopts is not None:
2159             self.diffopts = shlex.split(diffopts)
2160
2161         graphopts = config.get('graphOpts')
2162         if graphopts is not None:
2163             self.graphopts = shlex.split(graphopts)
2164
2165         logopts = config.get('logopts')
2166         if logopts is not None:
2167             self.logopts = shlex.split(logopts)
2168
2169         commitlogopts = config.get('commitlogopts')
2170         if commitlogopts is not None:
2171             self.commitlogopts = shlex.split(commitlogopts)
2172
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'
2178                 ):
2179             raise ConfigurationException(
2180                 '"author" is not an allowed setting for replyToRefchange'
2181                 )
2182         self.__reply_to_commit = config.get('replyToCommit', default=reply_to)
2183
2184         combine = config.get_bool('combineWhenSingleCommit')
2185         if combine is not None:
2186             self.combine_when_single_commit = combine
2187
2188     def get_administrator(self):
2189         return (
2190             self.config.get('administrator')
2191             or self.get_sender()
2192             or super(ConfigOptionsEnvironmentMixin, self).get_administrator()
2193             )
2194
2195     def get_repo_shortname(self):
2196         return (
2197             self.config.get('reponame')
2198             or super(ConfigOptionsEnvironmentMixin, self).get_repo_shortname()
2199             )
2200
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(),)
2211
2212     def get_sender(self):
2213         return self.config.get('envelopesender')
2214
2215     def get_fromaddr(self):
2216         fromaddr = self.config.get('from')
2217         if fromaddr:
2218             return fromaddr
2219         return super(ConfigOptionsEnvironmentMixin, self).get_fromaddr()
2220
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
2230
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
2242
2243     def get_scancommitforcc(self):
2244         return self.config.get('scancommitforcc')
2245
2246
2247 class FilterLinesEnvironmentMixin(Environment):
2248     """Handle encoding and maximum line length of body lines.
2249
2250         emailmaxlinelength (int or None)
2251
2252             The maximum length of any single line in the email body.
2253             Longer lines are truncated at that length with ' [...]'
2254             appended.
2255
2256         strict_utf8 (bool)
2257
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).
2262
2263     """
2264
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
2269
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)
2281
2282         return lines
2283
2284
2285 class ConfigFilterLinesEnvironmentMixin(
2286         ConfigEnvironmentMixin,
2287         FilterLinesEnvironmentMixin,
2288         ):
2289     """Handle encoding and maximum line length based on config."""
2290
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
2295
2296         emailmaxlinelength = config.get('emailmaxlinelength')
2297         if emailmaxlinelength is not None:
2298             kw['emailmaxlinelength'] = int(emailmaxlinelength)
2299
2300         super(ConfigFilterLinesEnvironmentMixin, self).__init__(
2301             config=config, **kw
2302             )
2303
2304
2305 class MaxlinesEnvironmentMixin(Environment):
2306     """Limit the email body to a specified number of lines."""
2307
2308     def __init__(self, emailmaxlines, **kw):
2309         super(MaxlinesEnvironmentMixin, self).__init__(**kw)
2310         self.__emailmaxlines = emailmaxlines
2311
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
2317
2318
2319 class ConfigMaxlinesEnvironmentMixin(
2320         ConfigEnvironmentMixin,
2321         MaxlinesEnvironmentMixin,
2322         ):
2323     """Limit the email body to the number of lines specified in config."""
2324
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
2331             )
2332
2333
2334 class FQDNEnvironmentMixin(Environment):
2335     """A mixin that sets the host's FQDN to its constructor argument."""
2336
2337     def __init__(self, fqdn, **kw):
2338         super(FQDNEnvironmentMixin, self).__init__(**kw)
2339         self.COMPUTED_KEYS += ['fqdn']
2340         self.__fqdn = fqdn
2341
2342     def get_fqdn(self):
2343         """Return the fully-qualified domain name for this host.
2344
2345         Return None if it is unavailable or unwanted."""
2346
2347         return self.__fqdn
2348
2349
2350 class ConfigFQDNEnvironmentMixin(
2351         ConfigEnvironmentMixin,
2352         FQDNEnvironmentMixin,
2353         ):
2354     """Read the FQDN from the config."""
2355
2356     def __init__(self, config, **kw):
2357         fqdn = config.get('fqdn')
2358         super(ConfigFQDNEnvironmentMixin, self).__init__(
2359             config=config,
2360             fqdn=fqdn,
2361             **kw
2362             )
2363
2364
2365 class ComputeFQDNEnvironmentMixin(FQDNEnvironmentMixin):
2366     """Get the FQDN by calling socket.getfqdn()."""
2367
2368     def __init__(self, **kw):
2369         super(ComputeFQDNEnvironmentMixin, self).__init__(
2370             fqdn=socket.getfqdn(),
2371             **kw
2372             )
2373
2374
2375 class PusherDomainEnvironmentMixin(ConfigEnvironmentMixin):
2376     """Deduce pusher_email from pusher by appending an emaildomain."""
2377
2378     def __init__(self, **kw):
2379         super(PusherDomainEnvironmentMixin, self).__init__(**kw)
2380         self.__emaildomain = self.config.get('emaildomain')
2381
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()
2388
2389
2390 class StaticRecipientsEnvironmentMixin(Environment):
2391     """Set recipients statically based on constructor parameters."""
2392
2393     def __init__(
2394             self,
2395             refchange_recipients, announce_recipients, revision_recipients, scancommitforcc,
2396             **kw
2397             ):
2398         super(StaticRecipientsEnvironmentMixin, self).__init__(**kw)
2399
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
2415
2416     def get_refchange_recipients(self, refchange):
2417         return self.__refchange_recipients
2418
2419     def get_announce_recipients(self, annotated_tag_change):
2420         return self.__announce_recipients
2421
2422     def get_revision_recipients(self, revision):
2423         return self.__revision_recipients
2424
2425
2426 class ConfigRecipientsEnvironmentMixin(
2427         ConfigEnvironmentMixin,
2428         StaticRecipientsEnvironmentMixin
2429         ):
2430     """Determine recipients statically based on config."""
2431
2432     def __init__(self, config, **kw):
2433         super(ConfigRecipientsEnvironmentMixin, self).__init__(
2434             config=config,
2435             refchange_recipients=self._get_recipients(
2436                 config, 'refchangelist', 'mailinglist',
2437                 ),
2438             announce_recipients=self._get_recipients(
2439                 config, 'announcelist', 'refchangelist', 'mailinglist',
2440                 ),
2441             revision_recipients=self._get_recipients(
2442                 config, 'commitlist', 'mailinglist',
2443                 ),
2444             scancommitforcc=config.get('scancommitforcc'),
2445             **kw
2446             )
2447
2448     def _get_recipients(self, config, *names):
2449         """Return the recipients for a particular type of message.
2450
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."""
2458
2459         for name in names:
2460             retval = config.get_recipients(name)
2461             if retval is not None:
2462                 return retval
2463         else:
2464             return ''
2465
2466
2467 class ProjectdescEnvironmentMixin(Environment):
2468     """Make a "projectdesc" value available for templates.
2469
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)."""
2472
2473     def __init__(self, **kw):
2474         super(ProjectdescEnvironmentMixin, self).__init__(**kw)
2475         self.COMPUTED_KEYS += ['projectdesc']
2476
2477     def get_projectdesc(self):
2478         """Return a one-line descripition of the project."""
2479
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
2487
2488         return 'UNNAMED PROJECT'
2489
2490
2491 class GenericEnvironmentMixin(Environment):
2492     def get_pusher(self):
2493         return self.osenv.get('USER', self.osenv.get('USERNAME', 'unknown user'))
2494
2495
2496 class GenericEnvironment(
2497         ProjectdescEnvironmentMixin,
2498         ConfigMaxlinesEnvironmentMixin,
2499         ComputeFQDNEnvironmentMixin,
2500         ConfigFilterLinesEnvironmentMixin,
2501         ConfigRecipientsEnvironmentMixin,
2502         PusherDomainEnvironmentMixin,
2503         ConfigOptionsEnvironmentMixin,
2504         GenericEnvironmentMixin,
2505         Environment,
2506         ):
2507     pass
2508
2509
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()
2518             )
2519
2520     def get_pusher(self):
2521         return self.osenv.get('GL_USER', 'unknown user')
2522
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',
2546                             ))
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()
2561
2562
2563 class IncrementalDateTime(object):
2564     """Simple wrapper to give incremental date/times.
2565
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."""
2570
2571     def __init__(self):
2572         self.time = time.time()
2573
2574     def next(self):
2575         formatted = formatdate(self.time, True)
2576         self.time += 1
2577         return formatted
2578
2579
2580 class GitoliteEnvironment(
2581         ProjectdescEnvironmentMixin,
2582         ConfigMaxlinesEnvironmentMixin,
2583         ComputeFQDNEnvironmentMixin,
2584         ConfigFilterLinesEnvironmentMixin,
2585         ConfigRecipientsEnvironmentMixin,
2586         PusherDomainEnvironmentMixin,
2587         ConfigOptionsEnvironmentMixin,
2588         GitoliteEnvironmentMixin,
2589         Environment,
2590         ):
2591     pass
2592
2593
2594 class Push(object):
2595     """Represent an entire push (i.e., a group of ReferenceChanges).
2596
2597     It is easy to figure out what commits were added to a *branch* by
2598     a Reference change:
2599
2600         git rev-list change.old..change.new
2601
2602     or removed from a *branch*:
2603
2604         git rev-list change.new..change.old
2605
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.
2612
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.
2617
2618     The commits contained in the repository before this push were
2619
2620         git rev-list other1 other2 other3 ... change1.old change2.old ...
2621
2622     Where "changeN.old" is the old value of one of the references
2623     affected by this push.
2624
2625     The commits contained in the repository after this push are
2626
2627         git rev-list other1 other2 other3 ... change1.new change2.new ...
2628
2629     The commits added by this push are the difference between these
2630     two sets, which can be written
2631
2632         git rev-list \
2633             ^other1 ^other2 ... \
2634             ^change1.old ^change2.old ... \
2635             change1.new change2.new ...
2636
2637     The commits removed by this push can be computed by
2638
2639         git rev-list \
2640             ^other1 ^other2 ... \
2641             ^change1.new ^change2.new ... \
2642             change1.old change2.old ...
2643
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)."""
2651
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'),
2673             ])
2674         )
2675
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 = {}
2680
2681         if ignore_other_refs:
2682             self.__other_ref_sha1s = set()
2683
2684     @classmethod
2685     def _sort_key(klass, change):
2686         return (klass.SORT_ORDER[change.__class__, change.change_type], change.refname,)
2687
2688     @property
2689     def _other_ref_sha1s(self):
2690         """The GitObjects referred to by references unaffected by this push.
2691         """
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
2697                 )
2698
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)'
2705                 )
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)
2711
2712             self.__other_ref_sha1s = sha1s
2713
2714         return self.__other_ref_sha1s
2715
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.
2718
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.
2724
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.
2729
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.
2733
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').
2736         """
2737
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)
2743                 )
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
2751
2752     def _get_commits_spec_excl(self, new_or_old):
2753         """Get exclusion revisions for determining new or discarded commits.
2754
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).
2762
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.  """
2767
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']
2773             )
2774         return ['^' + sha1 for sha1 in sorted(excl_revs)]
2775
2776     def get_commits_spec(self, new_or_old, reference_change=None):
2777         """Get rev-list arguments for added or discarded commits.
2778
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.
2783
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.
2789
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.
2793
2794         This function returns None if there are no added (or discarded)
2795         revisions.
2796         """
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]
2804
2805     def get_new_commits(self, reference_change=None):
2806         """Return a list of commits added by this push.
2807
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."""
2812
2813         spec = self.get_commits_spec('new', reference_change)
2814         return git_rev_list(spec)
2815
2816     def get_discarded_commits(self, reference_change):
2817         """Return a list of commits discarded by this push.
2818
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."""
2822
2823         spec = self.get_commits_spec('old', reference_change)
2824         return git_rev_list(spec)
2825
2826     def send_emails(self, mailer, body_filter=None):
2827         """Use send all of the notification emails needed for this push.
2828
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."""
2834
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)
2847
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,)
2854                     )
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()}
2860
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,
2866                         )
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,
2874                         )
2875
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
2882                     )
2883                 return
2884
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,
2896                         )
2897
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)),)
2904                 )
2905
2906
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)
2913             )
2914     push = Push(changes)
2915     push.send_emails(mailer, body_filter=environment.filter_body)
2916
2917
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,
2925             ),
2926         ]
2927     push = Push(changes, force_send)
2928     push.send_emails(mailer, body_filter=environment.filter_body)
2929
2930
2931 def choose_mailer(config, environment):
2932     mailer = config.get('mailer', default='sendmail')
2933
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,
2948             )
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'
2958             )
2959         sys.exit(1)
2960     return mailer
2961
2962
2963 KNOWN_ENVIRONMENTS = {
2964     'generic': GenericEnvironmentMixin,
2965     'gitolite': GitoliteEnvironmentMixin,
2966     }
2967
2968
2969 def choose_environment(config, osenv=None, env=None, recipients=None):
2970     if not osenv:
2971         osenv = os.environ
2972
2973     environment_mixins = [
2974         ProjectdescEnvironmentMixin,
2975         ConfigMaxlinesEnvironmentMixin,
2976         ComputeFQDNEnvironmentMixin,
2977         ConfigFilterLinesEnvironmentMixin,
2978         PusherDomainEnvironmentMixin,
2979         ConfigOptionsEnvironmentMixin,
2980         ]
2981     environment_kw = {
2982         'osenv': osenv,
2983         'config': config,
2984         }
2985
2986     if not env:
2987         env = config.get('environment')
2988
2989     if not env:
2990         if 'GL_USER' in osenv and 'GL_REPO' in osenv:
2991             env = 'gitolite'
2992         else:
2993             env = 'generic'
2994
2995     environment_mixins.append(KNOWN_ENVIRONMENTS[env])
2996
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)
3005
3006     environment_klass = type(
3007         'EffectiveEnvironment',
3008         tuple(environment_mixins) + (Environment,),
3009         {},
3010         )
3011     return environment_klass(**environment_kw)
3012
3013
3014 def main(args):
3015     parser = optparse.OptionParser(
3016         description=__doc__,
3017         usage='%prog [OPTIONS]\n   or: %prog [OPTIONS] REFNAME OLDREV NEWREV',
3018         )
3019
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".'
3026             ),
3027         )
3028     parser.add_option(
3029         '--stdout', action='store_true', default=False,
3030         help='Output emails to stdout rather than sending them.',
3031         )
3032     parser.add_option(
3033         '--recipients', action='store', default=None,
3034         help='Set list of email recipients for all types of emails.',
3035         )
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).'
3041             ),
3042         )
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.'
3049             ),
3050         )
3051
3052     (options, args) = parser.parse_args(args)
3053
3054     config = Config('multimailhook')
3055
3056     try:
3057         environment = choose_environment(
3058             config, osenv=os.environ,
3059             env=options.environment,
3060             recipients=options.recipients,
3061             )
3062
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')
3068
3069         if options.stdout or environment.stdout:
3070             mailer = OutputMailer(sys.stdout)
3071         else:
3072             mailer = choose_mailer(config, environment)
3073
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))
3085
3086
3087 if __name__ == '__main__':
3088     main(sys.argv[1:])