Merge branch 'rj/xdiff-prepare-plug-leak-on-error-codepath' into maint
[git] / contrib / hooks / multimail / git_multimail.py
1 #! /usr/bin/env python
2
3 __version__ = '1.2.0'
4
5 # Copyright (c) 2015 Matthieu Moy and others
6 # Copyright (c) 2012-2014 Michael Haggerty and others
7 # Derived from contrib/hooks/post-receive-email, which is
8 # Copyright (c) 2007 Andy Parkins
9 # and also includes contributions by other authors.
10 #
11 # This file is part of git-multimail.
12 #
13 # git-multimail is free software: you can redistribute it and/or
14 # modify it under the terms of the GNU General Public License version
15 # 2 as published by the Free Software Foundation.
16 #
17 # This program is distributed in the hope that it will be useful, but
18 # WITHOUT ANY WARRANTY; without even the implied warranty of
19 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
20 # General Public License for more details.
21 #
22 # You should have received a copy of the GNU General Public License
23 # along with this program.  If not, see
24 # <http://www.gnu.org/licenses/>.
25
26 """Generate notification emails for pushes to a git repository.
27
28 This hook sends emails describing changes introduced by pushes to a
29 git repository.  For each reference that was changed, it emits one
30 ReferenceChange email summarizing how the reference was changed,
31 followed by one Revision email for each new commit that was introduced
32 by the reference change.
33
34 Each commit is announced in exactly one Revision email.  If the same
35 commit is merged into another branch in the same or a later push, then
36 the ReferenceChange email will list the commit's SHA1 and its one-line
37 summary, but no new Revision email will be generated.
38
39 This script is designed to be used as a "post-receive" hook in a git
40 repository (see githooks(5)).  It can also be used as an "update"
41 script, but this usage is not completely reliable and is deprecated.
42
43 To help with debugging, this script accepts a --stdout option, which
44 causes the emails to be written to standard output rather than sent
45 using sendmail.
46
47 See the accompanying README file for the complete documentation.
48
49 """
50
51 import sys
52 import os
53 import re
54 import bisect
55 import socket
56 import subprocess
57 import shlex
58 import optparse
59 import smtplib
60 import time
61 import cgi
62
63 PYTHON3 = sys.version_info >= (3, 0)
64
65 if sys.version_info <= (2, 5):
66     def all(iterable):
67         for element in iterable:
68             if not element:
69                 return False
70             return True
71
72
73 def is_ascii(s):
74     return all(ord(c) < 128 and ord(c) > 0 for c in s)
75
76
77 if PYTHON3:
78     def str_to_bytes(s):
79         return s.encode(ENCODING)
80
81     def bytes_to_str(s):
82         return s.decode(ENCODING)
83
84     unicode = str
85
86     def write_str(f, msg):
87         # Try outputing with the default encoding. If it fails,
88         # try UTF-8.
89         try:
90             f.buffer.write(msg.encode(sys.getdefaultencoding()))
91         except UnicodeEncodeError:
92             f.buffer.write(msg.encode(ENCODING))
93 else:
94     def str_to_bytes(s):
95         return s
96
97     def bytes_to_str(s):
98         return s
99
100     def write_str(f, msg):
101         f.write(msg)
102
103     def next(it):
104         return it.next()
105
106
107 try:
108     from email.charset import Charset
109     from email.utils import make_msgid
110     from email.utils import getaddresses
111     from email.utils import formataddr
112     from email.utils import formatdate
113     from email.header import Header
114 except ImportError:
115     # Prior to Python 2.5, the email module used different names:
116     from email.Charset import Charset
117     from email.Utils import make_msgid
118     from email.Utils import getaddresses
119     from email.Utils import formataddr
120     from email.Utils import formatdate
121     from email.Header import Header
122
123
124 DEBUG = False
125
126 ZEROS = '0' * 40
127 LOGBEGIN = '- Log -----------------------------------------------------------------\n'
128 LOGEND = '-----------------------------------------------------------------------\n'
129
130 ADDR_HEADERS = set(['from', 'to', 'cc', 'bcc', 'reply-to', 'sender'])
131
132 # It is assumed in many places that the encoding is uniformly UTF-8,
133 # so changing these constants is unsupported.  But define them here
134 # anyway, to make it easier to find (at least most of) the places
135 # where the encoding is important.
136 (ENCODING, CHARSET) = ('UTF-8', 'utf-8')
137
138
139 REF_CREATED_SUBJECT_TEMPLATE = (
140     '%(emailprefix)s%(refname_type)s %(short_refname)s created'
141     ' (now %(newrev_short)s)'
142     )
143 REF_UPDATED_SUBJECT_TEMPLATE = (
144     '%(emailprefix)s%(refname_type)s %(short_refname)s updated'
145     ' (%(oldrev_short)s -> %(newrev_short)s)'
146     )
147 REF_DELETED_SUBJECT_TEMPLATE = (
148     '%(emailprefix)s%(refname_type)s %(short_refname)s deleted'
149     ' (was %(oldrev_short)s)'
150     )
151
152 COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE = (
153     '%(emailprefix)s%(refname_type)s %(short_refname)s updated: %(oneline)s'
154     )
155
156 REFCHANGE_HEADER_TEMPLATE = """\
157 Date: %(send_date)s
158 To: %(recipients)s
159 Subject: %(subject)s
160 MIME-Version: 1.0
161 Content-Type: text/%(contenttype)s; charset=%(charset)s
162 Content-Transfer-Encoding: 8bit
163 Message-ID: %(msgid)s
164 From: %(fromaddr)s
165 Reply-To: %(reply_to)s
166 X-Git-Host: %(fqdn)s
167 X-Git-Repo: %(repo_shortname)s
168 X-Git-Refname: %(refname)s
169 X-Git-Reftype: %(refname_type)s
170 X-Git-Oldrev: %(oldrev)s
171 X-Git-Newrev: %(newrev)s
172 X-Git-NotificationType: ref_changed
173 X-Git-Multimail-Version: %(multimail_version)s
174 Auto-Submitted: auto-generated
175 """
176
177 REFCHANGE_INTRO_TEMPLATE = """\
178 This is an automated email from the git hooks/post-receive script.
179
180 %(pusher)s pushed a change to %(refname_type)s %(short_refname)s
181 in repository %(repo_shortname)s.
182
183 """
184
185
186 FOOTER_TEMPLATE = """\
187
188 -- \n\
189 To stop receiving notification emails like this one, please contact
190 %(administrator)s.
191 """
192
193
194 REWIND_ONLY_TEMPLATE = """\
195 This update removed existing revisions from the reference, leaving the
196 reference pointing at a previous point in the repository history.
197
198  * -- * -- N   %(refname)s (%(newrev_short)s)
199             \\
200              O -- O -- O   (%(oldrev_short)s)
201
202 Any revisions marked "omits" are not gone; other references still
203 refer to them.  Any revisions marked "discards" are gone forever.
204 """
205
206
207 NON_FF_TEMPLATE = """\
208 This update added new revisions after undoing existing revisions.
209 That is to say, some revisions that were in the old version of the
210 %(refname_type)s are not in the new version.  This situation occurs
211 when a user --force pushes a change and generates a repository
212 containing something like this:
213
214  * -- * -- B -- O -- O -- O   (%(oldrev_short)s)
215             \\
216              N -- N -- N   %(refname)s (%(newrev_short)s)
217
218 You should already have received notification emails for all of the O
219 revisions, and so the following emails describe only the N revisions
220 from the common base, B.
221
222 Any revisions marked "omits" are not gone; other references still
223 refer to them.  Any revisions marked "discards" are gone forever.
224 """
225
226
227 NO_NEW_REVISIONS_TEMPLATE = """\
228 No new revisions were added by this update.
229 """
230
231
232 DISCARDED_REVISIONS_TEMPLATE = """\
233 This change permanently discards the following revisions:
234 """
235
236
237 NO_DISCARDED_REVISIONS_TEMPLATE = """\
238 The revisions that were on this %(refname_type)s are still contained in
239 other references; therefore, this change does not discard any commits
240 from the repository.
241 """
242
243
244 NEW_REVISIONS_TEMPLATE = """\
245 The %(tot)s revisions listed above as "new" are entirely new to this
246 repository and will be described in separate emails.  The revisions
247 listed as "adds" were already present in the repository and have only
248 been added to this reference.
249
250 """
251
252
253 TAG_CREATED_TEMPLATE = """\
254         at  %(newrev_short)-9s (%(newrev_type)s)
255 """
256
257
258 TAG_UPDATED_TEMPLATE = """\
259 *** WARNING: tag %(short_refname)s was modified! ***
260
261       from  %(oldrev_short)-9s (%(oldrev_type)s)
262         to  %(newrev_short)-9s (%(newrev_type)s)
263 """
264
265
266 TAG_DELETED_TEMPLATE = """\
267 *** WARNING: tag %(short_refname)s was deleted! ***
268
269 """
270
271
272 # The template used in summary tables.  It looks best if this uses the
273 # same alignment as TAG_CREATED_TEMPLATE and TAG_UPDATED_TEMPLATE.
274 BRIEF_SUMMARY_TEMPLATE = """\
275 %(action)10s  %(rev_short)-9s %(text)s
276 """
277
278
279 NON_COMMIT_UPDATE_TEMPLATE = """\
280 This is an unusual reference change because the reference did not
281 refer to a commit either before or after the change.  We do not know
282 how to provide full information about this reference change.
283 """
284
285
286 REVISION_HEADER_TEMPLATE = """\
287 Date: %(send_date)s
288 To: %(recipients)s
289 Cc: %(cc_recipients)s
290 Subject: %(emailprefix)s%(num)02d/%(tot)02d: %(oneline)s
291 MIME-Version: 1.0
292 Content-Type: text/%(contenttype)s; charset=%(charset)s
293 Content-Transfer-Encoding: 8bit
294 From: %(fromaddr)s
295 Reply-To: %(reply_to)s
296 In-Reply-To: %(reply_to_msgid)s
297 References: %(reply_to_msgid)s
298 X-Git-Host: %(fqdn)s
299 X-Git-Repo: %(repo_shortname)s
300 X-Git-Refname: %(refname)s
301 X-Git-Reftype: %(refname_type)s
302 X-Git-Rev: %(rev)s
303 X-Git-NotificationType: diff
304 X-Git-Multimail-Version: %(multimail_version)s
305 Auto-Submitted: auto-generated
306 """
307
308 REVISION_INTRO_TEMPLATE = """\
309 This is an automated email from the git hooks/post-receive script.
310
311 %(pusher)s pushed a commit to %(refname_type)s %(short_refname)s
312 in repository %(repo_shortname)s.
313
314 """
315
316
317 REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE
318
319
320 # Combined, meaning refchange+revision email (for single-commit additions)
321 COMBINED_HEADER_TEMPLATE = """\
322 Date: %(send_date)s
323 To: %(recipients)s
324 Subject: %(subject)s
325 MIME-Version: 1.0
326 Content-Type: text/%(contenttype)s; charset=%(charset)s
327 Content-Transfer-Encoding: 8bit
328 Message-ID: %(msgid)s
329 From: %(fromaddr)s
330 Reply-To: %(reply_to)s
331 X-Git-Host: %(fqdn)s
332 X-Git-Repo: %(repo_shortname)s
333 X-Git-Refname: %(refname)s
334 X-Git-Reftype: %(refname_type)s
335 X-Git-Oldrev: %(oldrev)s
336 X-Git-Newrev: %(newrev)s
337 X-Git-Rev: %(rev)s
338 X-Git-NotificationType: ref_changed_plus_diff
339 X-Git-Multimail-Version: %(multimail_version)s
340 Auto-Submitted: auto-generated
341 """
342
343 COMBINED_INTRO_TEMPLATE = """\
344 This is an automated email from the git hooks/post-receive script.
345
346 %(pusher)s pushed a commit to %(refname_type)s %(short_refname)s
347 in repository %(repo_shortname)s.
348
349 """
350
351 COMBINED_FOOTER_TEMPLATE = FOOTER_TEMPLATE
352
353
354 class CommandError(Exception):
355     def __init__(self, cmd, retcode):
356         self.cmd = cmd
357         self.retcode = retcode
358         Exception.__init__(
359             self,
360             'Command "%s" failed with retcode %s' % (' '.join(cmd), retcode,)
361             )
362
363
364 class ConfigurationException(Exception):
365     pass
366
367
368 # The "git" program (this could be changed to include a full path):
369 GIT_EXECUTABLE = 'git'
370
371
372 # How "git" should be invoked (including global arguments), as a list
373 # of words.  This variable is usually initialized automatically by
374 # read_git_output() via choose_git_command(), but if a value is set
375 # here then it will be used unconditionally.
376 GIT_CMD = None
377
378
379 def choose_git_command():
380     """Decide how to invoke git, and record the choice in GIT_CMD."""
381
382     global GIT_CMD
383
384     if GIT_CMD is None:
385         try:
386             # Check to see whether the "-c" option is accepted (it was
387             # only added in Git 1.7.2).  We don't actually use the
388             # output of "git --version", though if we needed more
389             # specific version information this would be the place to
390             # do it.
391             cmd = [GIT_EXECUTABLE, '-c', 'foo.bar=baz', '--version']
392             read_output(cmd)
393             GIT_CMD = [GIT_EXECUTABLE, '-c', 'i18n.logoutputencoding=%s' % (ENCODING,)]
394         except CommandError:
395             GIT_CMD = [GIT_EXECUTABLE]
396
397
398 def read_git_output(args, input=None, keepends=False, **kw):
399     """Read the output of a Git command."""
400
401     if GIT_CMD is None:
402         choose_git_command()
403
404     return read_output(GIT_CMD + args, input=input, keepends=keepends, **kw)
405
406
407 def read_output(cmd, input=None, keepends=False, **kw):
408     if input:
409         stdin = subprocess.PIPE
410         input = str_to_bytes(input)
411     else:
412         stdin = None
413     p = subprocess.Popen(
414         cmd, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw
415         )
416     (out, err) = p.communicate(input)
417     out = bytes_to_str(out)
418     retcode = p.wait()
419     if retcode:
420         raise CommandError(cmd, retcode)
421     if not keepends:
422         out = out.rstrip('\n\r')
423     return out
424
425
426 def read_git_lines(args, keepends=False, **kw):
427     """Return the lines output by Git command.
428
429     Return as single lines, with newlines stripped off."""
430
431     return read_git_output(args, keepends=True, **kw).splitlines(keepends)
432
433
434 def git_rev_list_ish(cmd, spec, args=None, **kw):
435     """Common functionality for invoking a 'git rev-list'-like command.
436
437     Parameters:
438       * cmd is the Git command to run, e.g., 'rev-list' or 'log'.
439       * spec is a list of revision arguments to pass to the named
440         command.  If None, this function returns an empty list.
441       * args is a list of extra arguments passed to the named command.
442       * All other keyword arguments (if any) are passed to the
443         underlying read_git_lines() function.
444
445     Return the output of the Git command in the form of a list, one
446     entry per output line.
447     """
448     if spec is None:
449         return []
450     if args is None:
451         args = []
452     args = [cmd, '--stdin'] + args
453     spec_stdin = ''.join(s + '\n' for s in spec)
454     return read_git_lines(args, input=spec_stdin, **kw)
455
456
457 def git_rev_list(spec, **kw):
458     """Run 'git rev-list' with the given list of revision arguments.
459
460     See git_rev_list_ish() for parameter and return value
461     documentation.
462     """
463     return git_rev_list_ish('rev-list', spec, **kw)
464
465
466 def git_log(spec, **kw):
467     """Run 'git log' with the given list of revision arguments.
468
469     See git_rev_list_ish() for parameter and return value
470     documentation.
471     """
472     return git_rev_list_ish('log', spec, **kw)
473
474
475 def header_encode(text, header_name=None):
476     """Encode and line-wrap the value of an email header field."""
477
478     # Convert to unicode, if required.
479     if not isinstance(text, unicode):
480         text = unicode(text, 'utf-8')
481
482     if is_ascii(text):
483         charset = 'ascii'
484     else:
485         charset = 'utf-8'
486
487     return Header(text, header_name=header_name, charset=Charset(charset)).encode()
488
489
490 def addr_header_encode(text, header_name=None):
491     """Encode and line-wrap the value of an email header field containing
492     email addresses."""
493
494     # Convert to unicode, if required.
495     if not isinstance(text, unicode):
496         text = unicode(text, 'utf-8')
497
498     text = ', '.join(
499         formataddr((header_encode(name), emailaddr))
500         for name, emailaddr in getaddresses([text])
501         )
502
503     if is_ascii(text):
504         charset = 'ascii'
505     else:
506         charset = 'utf-8'
507
508     return Header(text, header_name=header_name, charset=Charset(charset)).encode()
509
510
511 class Config(object):
512     def __init__(self, section, git_config=None):
513         """Represent a section of the git configuration.
514
515         If git_config is specified, it is passed to "git config" in
516         the GIT_CONFIG environment variable, meaning that "git config"
517         will read the specified path rather than the Git default
518         config paths."""
519
520         self.section = section
521         if git_config:
522             self.env = os.environ.copy()
523             self.env['GIT_CONFIG'] = git_config
524         else:
525             self.env = None
526
527     @staticmethod
528     def _split(s):
529         """Split NUL-terminated values."""
530
531         words = s.split('\0')
532         assert words[-1] == ''
533         return words[:-1]
534
535     def get(self, name, default=None):
536         try:
537             values = self._split(read_git_output(
538                 ['config', '--get', '--null', '%s.%s' % (self.section, name)],
539                 env=self.env, keepends=True,
540                 ))
541             assert len(values) == 1
542             return values[0]
543         except CommandError:
544             return default
545
546     def get_bool(self, name, default=None):
547         try:
548             value = read_git_output(
549                 ['config', '--get', '--bool', '%s.%s' % (self.section, name)],
550                 env=self.env,
551                 )
552         except CommandError:
553             return default
554         return value == 'true'
555
556     def get_all(self, name, default=None):
557         """Read a (possibly multivalued) setting from the configuration.
558
559         Return the result as a list of values, or default if the name
560         is unset."""
561
562         try:
563             return self._split(read_git_output(
564                 ['config', '--get-all', '--null', '%s.%s' % (self.section, name)],
565                 env=self.env, keepends=True,
566                 ))
567         except CommandError:
568             t, e, traceback = sys.exc_info()
569             if e.retcode == 1:
570                 # "the section or key is invalid"; i.e., there is no
571                 # value for the specified key.
572                 return default
573             else:
574                 raise
575
576     def set(self, name, value):
577         read_git_output(
578             ['config', '%s.%s' % (self.section, name), value],
579             env=self.env,
580             )
581
582     def add(self, name, value):
583         read_git_output(
584             ['config', '--add', '%s.%s' % (self.section, name), value],
585             env=self.env,
586             )
587
588     def __contains__(self, name):
589         return self.get_all(name, default=None) is not None
590
591     # We don't use this method anymore internally, but keep it here in
592     # case somebody is calling it from their own code:
593     def has_key(self, name):
594         return name in self
595
596     def unset_all(self, name):
597         try:
598             read_git_output(
599                 ['config', '--unset-all', '%s.%s' % (self.section, name)],
600                 env=self.env,
601                 )
602         except CommandError:
603             t, e, traceback = sys.exc_info()
604             if e.retcode == 5:
605                 # The name doesn't exist, which is what we wanted anyway...
606                 pass
607             else:
608                 raise
609
610     def set_recipients(self, name, value):
611         self.unset_all(name)
612         for pair in getaddresses([value]):
613             self.add(name, formataddr(pair))
614
615
616 def generate_summaries(*log_args):
617     """Generate a brief summary for each revision requested.
618
619     log_args are strings that will be passed directly to "git log" as
620     revision selectors.  Iterate over (sha1_short, subject) for each
621     commit specified by log_args (subject is the first line of the
622     commit message as a string without EOLs)."""
623
624     cmd = [
625         'log', '--abbrev', '--format=%h %s',
626         ] + list(log_args) + ['--']
627     for line in read_git_lines(cmd):
628         yield tuple(line.split(' ', 1))
629
630
631 def limit_lines(lines, max_lines):
632     for (index, line) in enumerate(lines):
633         if index < max_lines:
634             yield line
635
636     if index >= max_lines:
637         yield '... %d lines suppressed ...\n' % (index + 1 - max_lines,)
638
639
640 def limit_linelength(lines, max_linelength):
641     for line in lines:
642         # Don't forget that lines always include a trailing newline.
643         if len(line) > max_linelength + 1:
644             line = line[:max_linelength - 7] + ' [...]\n'
645         yield line
646
647
648 class CommitSet(object):
649     """A (constant) set of object names.
650
651     The set should be initialized with full SHA1 object names.  The
652     __contains__() method returns True iff its argument is an
653     abbreviation of any the names in the set."""
654
655     def __init__(self, names):
656         self._names = sorted(names)
657
658     def __len__(self):
659         return len(self._names)
660
661     def __contains__(self, sha1_abbrev):
662         """Return True iff this set contains sha1_abbrev (which might be abbreviated)."""
663
664         i = bisect.bisect_left(self._names, sha1_abbrev)
665         return i < len(self) and self._names[i].startswith(sha1_abbrev)
666
667
668 class GitObject(object):
669     def __init__(self, sha1, type=None):
670         if sha1 == ZEROS:
671             self.sha1 = self.type = self.commit_sha1 = None
672         else:
673             self.sha1 = sha1
674             self.type = type or read_git_output(['cat-file', '-t', self.sha1])
675
676             if self.type == 'commit':
677                 self.commit_sha1 = self.sha1
678             elif self.type == 'tag':
679                 try:
680                     self.commit_sha1 = read_git_output(
681                         ['rev-parse', '--verify', '%s^0' % (self.sha1,)]
682                         )
683                 except CommandError:
684                     # Cannot deref tag to determine commit_sha1
685                     self.commit_sha1 = None
686             else:
687                 self.commit_sha1 = None
688
689         self.short = read_git_output(['rev-parse', '--short', sha1])
690
691     def get_summary(self):
692         """Return (sha1_short, subject) for this commit."""
693
694         if not self.sha1:
695             raise ValueError('Empty commit has no summary')
696
697         return next(iter(generate_summaries('--no-walk', self.sha1)))
698
699     def __eq__(self, other):
700         return isinstance(other, GitObject) and self.sha1 == other.sha1
701
702     def __hash__(self):
703         return hash(self.sha1)
704
705     def __nonzero__(self):
706         return bool(self.sha1)
707
708     def __bool__(self):
709         """Python 2 backward compatibility"""
710         return self.__nonzero__()
711
712     def __str__(self):
713         return self.sha1 or ZEROS
714
715
716 class Change(object):
717     """A Change that has been made to the Git repository.
718
719     Abstract class from which both Revisions and ReferenceChanges are
720     derived.  A Change knows how to generate a notification email
721     describing itself."""
722
723     def __init__(self, environment):
724         self.environment = environment
725         self._values = None
726         self._contains_html_diff = False
727
728     def _contains_diff(self):
729         # We do contain a diff, should it be rendered in HTML?
730         if self.environment.commit_email_format == "html":
731             self._contains_html_diff = True
732
733     def _compute_values(self):
734         """Return a dictionary {keyword: expansion} for this Change.
735
736         Derived classes overload this method to add more entries to
737         the return value.  This method is used internally by
738         get_values().  The return value should always be a new
739         dictionary."""
740
741         values = self.environment.get_values()
742         fromaddr = self.environment.get_fromaddr(change=self)
743         if fromaddr is not None:
744             values['fromaddr'] = fromaddr
745         values['multimail_version'] = get_version()
746         return values
747
748     def get_values(self, **extra_values):
749         """Return a dictionary {keyword: expansion} for this Change.
750
751         Return a dictionary mapping keywords to the values that they
752         should be expanded to for this Change (used when interpolating
753         template strings).  If any keyword arguments are supplied, add
754         those to the return value as well.  The return value is always
755         a new dictionary."""
756
757         if self._values is None:
758             self._values = self._compute_values()
759
760         values = self._values.copy()
761         if extra_values:
762             values.update(extra_values)
763         return values
764
765     def expand(self, template, **extra_values):
766         """Expand template.
767
768         Expand the template (which should be a string) using string
769         interpolation of the values for this Change.  If any keyword
770         arguments are provided, also include those in the keywords
771         available for interpolation."""
772
773         return template % self.get_values(**extra_values)
774
775     def expand_lines(self, template, **extra_values):
776         """Break template into lines and expand each line."""
777
778         values = self.get_values(**extra_values)
779         for line in template.splitlines(True):
780             yield line % values
781
782     def expand_header_lines(self, template, **extra_values):
783         """Break template into lines and expand each line as an RFC 2822 header.
784
785         Encode values and split up lines that are too long.  Silently
786         skip lines that contain references to unknown variables."""
787
788         values = self.get_values(**extra_values)
789         if self._contains_html_diff:
790             values['contenttype'] = 'html'
791         else:
792             values['contenttype'] = 'plain'
793
794         for line in template.splitlines():
795             (name, value) = line.split(': ', 1)
796
797             try:
798                 value = value % values
799             except KeyError:
800                 t, e, traceback = sys.exc_info()
801                 if DEBUG:
802                     self.environment.log_warning(
803                         'Warning: unknown variable %r in the following line; line skipped:\n'
804                         '    %s\n'
805                         % (e.args[0], line,)
806                         )
807             else:
808                 if name.lower() in ADDR_HEADERS:
809                     value = addr_header_encode(value, name)
810                 else:
811                     value = header_encode(value, name)
812                 for splitline in ('%s: %s\n' % (name, value)).splitlines(True):
813                     yield splitline
814
815     def generate_email_header(self):
816         """Generate the RFC 2822 email headers for this Change, a line at a time.
817
818         The output should not include the trailing blank line."""
819
820         raise NotImplementedError()
821
822     def generate_email_intro(self):
823         """Generate the email intro for this Change, a line at a time.
824
825         The output will be used as the standard boilerplate at the top
826         of the email body."""
827
828         raise NotImplementedError()
829
830     def generate_email_body(self):
831         """Generate the main part of the email body, a line at a time.
832
833         The text in the body might be truncated after a specified
834         number of lines (see multimailhook.emailmaxlines)."""
835
836         raise NotImplementedError()
837
838     def generate_email_footer(self):
839         """Generate the footer of the email, a line at a time.
840
841         The footer is always included, irrespective of
842         multimailhook.emailmaxlines."""
843
844         raise NotImplementedError()
845
846     def _wrap_for_html(self, lines):
847         """Wrap the lines in HTML <pre> tag when using HTML format.
848
849         Escape special HTML characters and add <pre> and </pre> tags around
850         the given lines if we should be generating HTML as indicated by
851         self._contains_html_diff being set to true.
852         """
853         if self._contains_html_diff:
854             yield "<pre style='margin:0'>\n"
855
856             for line in lines:
857                 yield cgi.escape(line)
858
859             yield '</pre>\n'
860         else:
861             for line in lines:
862                 yield line
863
864     def generate_email(self, push, body_filter=None, extra_header_values={}):
865         """Generate an email describing this change.
866
867         Iterate over the lines (including the header lines) of an
868         email describing this change.  If body_filter is not None,
869         then use it to filter the lines that are intended for the
870         email body.
871
872         The extra_header_values field is received as a dict and not as
873         **kwargs, to allow passing other keyword arguments in the
874         future (e.g. passing extra values to generate_email_intro()"""
875
876         for line in self.generate_email_header(**extra_header_values):
877             yield line
878         yield '\n'
879         for line in self._wrap_for_html(self.generate_email_intro()):
880             yield line
881
882         body = self.generate_email_body(push)
883         if body_filter is not None:
884             body = body_filter(body)
885
886         diff_started = False
887         if self._contains_html_diff:
888             # "white-space: pre" is the default, but we need to
889             # specify it again in case the message is viewed in a
890             # webmail which wraps it in an element setting white-space
891             # to something else (Zimbra does this and sets
892             # white-space: pre-line).
893             yield '<pre style="white-space: pre; background: #F8F8F8">'
894         for line in body:
895             if self._contains_html_diff:
896                 # This is very, very naive. It would be much better to really
897                 # parse the diff, i.e. look at how many lines do we have in
898                 # the hunk headers instead of blindly highlighting everything
899                 # that looks like it might be part of a diff.
900                 bgcolor = ''
901                 fgcolor = ''
902                 if line.startswith('--- a/'):
903                     diff_started = True
904                     bgcolor = 'e0e0ff'
905                 elif line.startswith('diff ') or line.startswith('index '):
906                     diff_started = True
907                     fgcolor = '808080'
908                 elif diff_started:
909                     if line.startswith('+++ '):
910                         bgcolor = 'e0e0ff'
911                     elif line.startswith('@@'):
912                         bgcolor = 'e0e0e0'
913                     elif line.startswith('+'):
914                         bgcolor = 'e0ffe0'
915                     elif line.startswith('-'):
916                         bgcolor = 'ffe0e0'
917                 elif line.startswith('commit '):
918                     fgcolor = '808000'
919                 elif line.startswith('    '):
920                     fgcolor = '404040'
921
922                 # Chop the trailing LF, we don't want it inside <pre>.
923                 line = cgi.escape(line[:-1])
924
925                 if bgcolor or fgcolor:
926                     style = 'display:block; white-space:pre;'
927                     if bgcolor:
928                         style += 'background:#' + bgcolor + ';'
929                     if fgcolor:
930                         style += 'color:#' + fgcolor + ';'
931                     # Use a <span style='display:block> to color the
932                     # whole line. The newline must be inside the span
933                     # to display properly both in Firefox and in
934                     # text-based browser.
935                     line = "<span style='%s'>%s\n</span>" % (style, line)
936                 else:
937                     line = line + '\n'
938
939             yield line
940         if self._contains_html_diff:
941             yield '</pre>'
942
943         for line in self._wrap_for_html(self.generate_email_footer()):
944             yield line
945
946     def get_alt_fromaddr(self):
947         return None
948
949
950 class Revision(Change):
951     """A Change consisting of a single git commit."""
952
953     CC_RE = re.compile(r'^\s*C[Cc]:\s*(?P<to>[^#]+@[^\s#]*)\s*(#.*)?$')
954
955     def __init__(self, reference_change, rev, num, tot):
956         Change.__init__(self, reference_change.environment)
957         self.reference_change = reference_change
958         self.rev = rev
959         self.change_type = self.reference_change.change_type
960         self.refname = self.reference_change.refname
961         self.num = num
962         self.tot = tot
963         self.author = read_git_output(['log', '--no-walk', '--format=%aN <%aE>', self.rev.sha1])
964         self.recipients = self.environment.get_revision_recipients(self)
965
966         self.cc_recipients = ''
967         if self.environment.get_scancommitforcc():
968             self.cc_recipients = ', '.join(to.strip() for to in self._cc_recipients())
969             if self.cc_recipients:
970                 self.environment.log_msg(
971                     'Add %s to CC for %s\n' % (self.cc_recipients, self.rev.sha1))
972
973     def _cc_recipients(self):
974         cc_recipients = []
975         message = read_git_output(['log', '--no-walk', '--format=%b', self.rev.sha1])
976         lines = message.strip().split('\n')
977         for line in lines:
978             m = re.match(self.CC_RE, line)
979             if m:
980                 cc_recipients.append(m.group('to'))
981
982         return cc_recipients
983
984     def _compute_values(self):
985         values = Change._compute_values(self)
986
987         oneline = read_git_output(
988             ['log', '--format=%s', '--no-walk', self.rev.sha1]
989             )
990
991         values['rev'] = self.rev.sha1
992         values['rev_short'] = self.rev.short
993         values['change_type'] = self.change_type
994         values['refname'] = self.refname
995         values['short_refname'] = self.reference_change.short_refname
996         values['refname_type'] = self.reference_change.refname_type
997         values['reply_to_msgid'] = self.reference_change.msgid
998         values['num'] = self.num
999         values['tot'] = self.tot
1000         values['recipients'] = self.recipients
1001         if self.cc_recipients:
1002             values['cc_recipients'] = self.cc_recipients
1003         values['oneline'] = oneline
1004         values['author'] = self.author
1005
1006         reply_to = self.environment.get_reply_to_commit(self)
1007         if reply_to:
1008             values['reply_to'] = reply_to
1009
1010         return values
1011
1012     def generate_email_header(self, **extra_values):
1013         for line in self.expand_header_lines(
1014                 REVISION_HEADER_TEMPLATE, **extra_values
1015                 ):
1016             yield line
1017
1018     def generate_email_intro(self):
1019         for line in self.expand_lines(REVISION_INTRO_TEMPLATE):
1020             yield line
1021
1022     def generate_email_body(self, push):
1023         """Show this revision."""
1024
1025         for line in read_git_lines(
1026                 ['log'] + self.environment.commitlogopts + ['-1', self.rev.sha1],
1027                 keepends=True,
1028                 ):
1029             if line.startswith('Date:   ') and self.environment.date_substitute:
1030                 yield self.environment.date_substitute + line[len('Date:   '):]
1031             else:
1032                 yield line
1033
1034     def generate_email_footer(self):
1035         return self.expand_lines(REVISION_FOOTER_TEMPLATE)
1036
1037     def generate_email(self, push, body_filter=None, extra_header_values={}):
1038         self._contains_diff()
1039         return Change.generate_email(self, push, body_filter, extra_header_values)
1040
1041     def get_alt_fromaddr(self):
1042         return self.environment.from_commit
1043
1044
1045 class ReferenceChange(Change):
1046     """A Change to a Git reference.
1047
1048     An abstract class representing a create, update, or delete of a
1049     Git reference.  Derived classes handle specific types of reference
1050     (e.g., tags vs. branches).  These classes generate the main
1051     reference change email summarizing the reference change and
1052     whether it caused any any commits to be added or removed.
1053
1054     ReferenceChange objects are usually created using the static
1055     create() method, which has the logic to decide which derived class
1056     to instantiate."""
1057
1058     REF_RE = re.compile(r'^refs\/(?P<area>[^\/]+)\/(?P<shortname>.*)$')
1059
1060     @staticmethod
1061     def create(environment, oldrev, newrev, refname):
1062         """Return a ReferenceChange object representing the change.
1063
1064         Return an object that represents the type of change that is being
1065         made. oldrev and newrev should be SHA1s or ZEROS."""
1066
1067         old = GitObject(oldrev)
1068         new = GitObject(newrev)
1069         rev = new or old
1070
1071         # The revision type tells us what type the commit is, combined with
1072         # the location of the ref we can decide between
1073         #  - working branch
1074         #  - tracking branch
1075         #  - unannotated tag
1076         #  - annotated tag
1077         m = ReferenceChange.REF_RE.match(refname)
1078         if m:
1079             area = m.group('area')
1080             short_refname = m.group('shortname')
1081         else:
1082             area = ''
1083             short_refname = refname
1084
1085         if rev.type == 'tag':
1086             # Annotated tag:
1087             klass = AnnotatedTagChange
1088         elif rev.type == 'commit':
1089             if area == 'tags':
1090                 # Non-annotated tag:
1091                 klass = NonAnnotatedTagChange
1092             elif area == 'heads':
1093                 # Branch:
1094                 klass = BranchChange
1095             elif area == 'remotes':
1096                 # Tracking branch:
1097                 environment.log_warning(
1098                     '*** Push-update of tracking branch %r\n'
1099                     '***  - incomplete email generated.\n'
1100                     % (refname,)
1101                     )
1102                 klass = OtherReferenceChange
1103             else:
1104                 # Some other reference namespace:
1105                 environment.log_warning(
1106                     '*** Push-update of strange reference %r\n'
1107                     '***  - incomplete email generated.\n'
1108                     % (refname,)
1109                     )
1110                 klass = OtherReferenceChange
1111         else:
1112             # Anything else (is there anything else?)
1113             environment.log_warning(
1114                 '*** Unknown type of update to %r (%s)\n'
1115                 '***  - incomplete email generated.\n'
1116                 % (refname, rev.type,)
1117                 )
1118             klass = OtherReferenceChange
1119
1120         return klass(
1121             environment,
1122             refname=refname, short_refname=short_refname,
1123             old=old, new=new, rev=rev,
1124             )
1125
1126     def __init__(self, environment, refname, short_refname, old, new, rev):
1127         Change.__init__(self, environment)
1128         self.change_type = {
1129             (False, True): 'create',
1130             (True, True): 'update',
1131             (True, False): 'delete',
1132             }[bool(old), bool(new)]
1133         self.refname = refname
1134         self.short_refname = short_refname
1135         self.old = old
1136         self.new = new
1137         self.rev = rev
1138         self.msgid = make_msgid()
1139         self.diffopts = environment.diffopts
1140         self.graphopts = environment.graphopts
1141         self.logopts = environment.logopts
1142         self.commitlogopts = environment.commitlogopts
1143         self.showgraph = environment.refchange_showgraph
1144         self.showlog = environment.refchange_showlog
1145
1146         self.header_template = REFCHANGE_HEADER_TEMPLATE
1147         self.intro_template = REFCHANGE_INTRO_TEMPLATE
1148         self.footer_template = FOOTER_TEMPLATE
1149
1150     def _compute_values(self):
1151         values = Change._compute_values(self)
1152
1153         values['change_type'] = self.change_type
1154         values['refname_type'] = self.refname_type
1155         values['refname'] = self.refname
1156         values['short_refname'] = self.short_refname
1157         values['msgid'] = self.msgid
1158         values['recipients'] = self.recipients
1159         values['oldrev'] = str(self.old)
1160         values['oldrev_short'] = self.old.short
1161         values['newrev'] = str(self.new)
1162         values['newrev_short'] = self.new.short
1163
1164         if self.old:
1165             values['oldrev_type'] = self.old.type
1166         if self.new:
1167             values['newrev_type'] = self.new.type
1168
1169         reply_to = self.environment.get_reply_to_refchange(self)
1170         if reply_to:
1171             values['reply_to'] = reply_to
1172
1173         return values
1174
1175     def send_single_combined_email(self, known_added_sha1s):
1176         """Determine if a combined refchange/revision email should be sent
1177
1178         If there is only a single new (non-merge) commit added by a
1179         change, it is useful to combine the ReferenceChange and
1180         Revision emails into one.  In such a case, return the single
1181         revision; otherwise, return None.
1182
1183         This method is overridden in BranchChange."""
1184
1185         return None
1186
1187     def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):
1188         """Generate an email describing this change AND specified revision.
1189
1190         Iterate over the lines (including the header lines) of an
1191         email describing this change.  If body_filter is not None,
1192         then use it to filter the lines that are intended for the
1193         email body.
1194
1195         The extra_header_values field is received as a dict and not as
1196         **kwargs, to allow passing other keyword arguments in the
1197         future (e.g. passing extra values to generate_email_intro()
1198
1199         This method is overridden in BranchChange."""
1200
1201         raise NotImplementedError
1202
1203     def get_subject(self):
1204         template = {
1205             'create': REF_CREATED_SUBJECT_TEMPLATE,
1206             'update': REF_UPDATED_SUBJECT_TEMPLATE,
1207             'delete': REF_DELETED_SUBJECT_TEMPLATE,
1208             }[self.change_type]
1209         return self.expand(template)
1210
1211     def generate_email_header(self, **extra_values):
1212         if 'subject' not in extra_values:
1213             extra_values['subject'] = self.get_subject()
1214
1215         for line in self.expand_header_lines(
1216                 self.header_template, **extra_values
1217                 ):
1218             yield line
1219
1220     def generate_email_intro(self):
1221         for line in self.expand_lines(self.intro_template):
1222             yield line
1223
1224     def generate_email_body(self, push):
1225         """Call the appropriate body-generation routine.
1226
1227         Call one of generate_create_summary() /
1228         generate_update_summary() / generate_delete_summary()."""
1229
1230         change_summary = {
1231             'create': self.generate_create_summary,
1232             'delete': self.generate_delete_summary,
1233             'update': self.generate_update_summary,
1234             }[self.change_type](push)
1235         for line in change_summary:
1236             yield line
1237
1238         for line in self.generate_revision_change_summary(push):
1239             yield line
1240
1241     def generate_email_footer(self):
1242         return self.expand_lines(self.footer_template)
1243
1244     def generate_revision_change_graph(self, push):
1245         if self.showgraph:
1246             args = ['--graph'] + self.graphopts
1247             for newold in ('new', 'old'):
1248                 has_newold = False
1249                 spec = push.get_commits_spec(newold, self)
1250                 for line in git_log(spec, args=args, keepends=True):
1251                     if not has_newold:
1252                         has_newold = True
1253                         yield '\n'
1254                         yield 'Graph of %s commits:\n\n' % (
1255                             {'new': 'new', 'old': 'discarded'}[newold],)
1256                     yield '  ' + line
1257                 if has_newold:
1258                     yield '\n'
1259
1260     def generate_revision_change_log(self, new_commits_list):
1261         if self.showlog:
1262             yield '\n'
1263             yield 'Detailed log of new commits:\n\n'
1264             for line in read_git_lines(
1265                     ['log', '--no-walk'] +
1266                     self.logopts +
1267                     new_commits_list +
1268                     ['--'],
1269                     keepends=True,
1270                     ):
1271                 yield line
1272
1273     def generate_new_revision_summary(self, tot, new_commits_list, push):
1274         for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot):
1275             yield line
1276         for line in self.generate_revision_change_graph(push):
1277             yield line
1278         for line in self.generate_revision_change_log(new_commits_list):
1279             yield line
1280
1281     def generate_revision_change_summary(self, push):
1282         """Generate a summary of the revisions added/removed by this change."""
1283
1284         if self.new.commit_sha1 and not self.old.commit_sha1:
1285             # A new reference was created.  List the new revisions
1286             # brought by the new reference (i.e., those revisions that
1287             # were not in the repository before this reference
1288             # change).
1289             sha1s = list(push.get_new_commits(self))
1290             sha1s.reverse()
1291             tot = len(sha1s)
1292             new_revisions = [
1293                 Revision(self, GitObject(sha1), num=i + 1, tot=tot)
1294                 for (i, sha1) in enumerate(sha1s)
1295                 ]
1296
1297             if new_revisions:
1298                 yield self.expand('This %(refname_type)s includes the following new commits:\n')
1299                 yield '\n'
1300                 for r in new_revisions:
1301                     (sha1, subject) = r.rev.get_summary()
1302                     yield r.expand(
1303                         BRIEF_SUMMARY_TEMPLATE, action='new', text=subject,
1304                         )
1305                 yield '\n'
1306                 for line in self.generate_new_revision_summary(
1307                         tot, [r.rev.sha1 for r in new_revisions], push):
1308                     yield line
1309             else:
1310                 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
1311                     yield line
1312
1313         elif self.new.commit_sha1 and self.old.commit_sha1:
1314             # A reference was changed to point at a different commit.
1315             # List the revisions that were removed and/or added *from
1316             # that reference* by this reference change, along with a
1317             # diff between the trees for its old and new values.
1318
1319             # List of the revisions that were added to the branch by
1320             # this update.  Note this list can include revisions that
1321             # have already had notification emails; we want such
1322             # revisions in the summary even though we will not send
1323             # new notification emails for them.
1324             adds = list(generate_summaries(
1325                 '--topo-order', '--reverse', '%s..%s'
1326                 % (self.old.commit_sha1, self.new.commit_sha1,)
1327                 ))
1328
1329             # List of the revisions that were removed from the branch
1330             # by this update.  This will be empty except for
1331             # non-fast-forward updates.
1332             discards = list(generate_summaries(
1333                 '%s..%s' % (self.new.commit_sha1, self.old.commit_sha1,)
1334                 ))
1335
1336             if adds:
1337                 new_commits_list = push.get_new_commits(self)
1338             else:
1339                 new_commits_list = []
1340             new_commits = CommitSet(new_commits_list)
1341
1342             if discards:
1343                 discarded_commits = CommitSet(push.get_discarded_commits(self))
1344             else:
1345                 discarded_commits = CommitSet([])
1346
1347             if discards and adds:
1348                 for (sha1, subject) in discards:
1349                     if sha1 in discarded_commits:
1350                         action = 'discards'
1351                     else:
1352                         action = 'omits'
1353                     yield self.expand(
1354                         BRIEF_SUMMARY_TEMPLATE, action=action,
1355                         rev_short=sha1, text=subject,
1356                         )
1357                 for (sha1, subject) in adds:
1358                     if sha1 in new_commits:
1359                         action = 'new'
1360                     else:
1361                         action = 'adds'
1362                     yield self.expand(
1363                         BRIEF_SUMMARY_TEMPLATE, action=action,
1364                         rev_short=sha1, text=subject,
1365                         )
1366                 yield '\n'
1367                 for line in self.expand_lines(NON_FF_TEMPLATE):
1368                     yield line
1369
1370             elif discards:
1371                 for (sha1, subject) in discards:
1372                     if sha1 in discarded_commits:
1373                         action = 'discards'
1374                     else:
1375                         action = 'omits'
1376                     yield self.expand(
1377                         BRIEF_SUMMARY_TEMPLATE, action=action,
1378                         rev_short=sha1, text=subject,
1379                         )
1380                 yield '\n'
1381                 for line in self.expand_lines(REWIND_ONLY_TEMPLATE):
1382                     yield line
1383
1384             elif adds:
1385                 (sha1, subject) = self.old.get_summary()
1386                 yield self.expand(
1387                     BRIEF_SUMMARY_TEMPLATE, action='from',
1388                     rev_short=sha1, text=subject,
1389                     )
1390                 for (sha1, subject) in adds:
1391                     if sha1 in new_commits:
1392                         action = 'new'
1393                     else:
1394                         action = 'adds'
1395                     yield self.expand(
1396                         BRIEF_SUMMARY_TEMPLATE, action=action,
1397                         rev_short=sha1, text=subject,
1398                         )
1399
1400             yield '\n'
1401
1402             if new_commits:
1403                 for line in self.generate_new_revision_summary(
1404                         len(new_commits), new_commits_list, push):
1405                     yield line
1406             else:
1407                 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
1408                     yield line
1409                 for line in self.generate_revision_change_graph(push):
1410                     yield line
1411
1412             # The diffstat is shown from the old revision to the new
1413             # revision.  This is to show the truth of what happened in
1414             # this change.  There's no point showing the stat from the
1415             # base to the new revision because the base is effectively a
1416             # random revision at this point - the user will be interested
1417             # in what this revision changed - including the undoing of
1418             # previous revisions in the case of non-fast-forward updates.
1419             yield '\n'
1420             yield 'Summary of changes:\n'
1421             for line in read_git_lines(
1422                     ['diff-tree'] +
1423                     self.diffopts +
1424                     ['%s..%s' % (self.old.commit_sha1, self.new.commit_sha1,)],
1425                     keepends=True,
1426                     ):
1427                 yield line
1428
1429         elif self.old.commit_sha1 and not self.new.commit_sha1:
1430             # A reference was deleted.  List the revisions that were
1431             # removed from the repository by this reference change.
1432
1433             sha1s = list(push.get_discarded_commits(self))
1434             tot = len(sha1s)
1435             discarded_revisions = [
1436                 Revision(self, GitObject(sha1), num=i + 1, tot=tot)
1437                 for (i, sha1) in enumerate(sha1s)
1438                 ]
1439
1440             if discarded_revisions:
1441                 for line in self.expand_lines(DISCARDED_REVISIONS_TEMPLATE):
1442                     yield line
1443                 yield '\n'
1444                 for r in discarded_revisions:
1445                     (sha1, subject) = r.rev.get_summary()
1446                     yield r.expand(
1447                         BRIEF_SUMMARY_TEMPLATE, action='discards', text=subject,
1448                         )
1449                 for line in self.generate_revision_change_graph(push):
1450                     yield line
1451             else:
1452                 for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE):
1453                     yield line
1454
1455         elif not self.old.commit_sha1 and not self.new.commit_sha1:
1456             for line in self.expand_lines(NON_COMMIT_UPDATE_TEMPLATE):
1457                 yield line
1458
1459     def generate_create_summary(self, push):
1460         """Called for the creation of a reference."""
1461
1462         # This is a new reference and so oldrev is not valid
1463         (sha1, subject) = self.new.get_summary()
1464         yield self.expand(
1465             BRIEF_SUMMARY_TEMPLATE, action='at',
1466             rev_short=sha1, text=subject,
1467             )
1468         yield '\n'
1469
1470     def generate_update_summary(self, push):
1471         """Called for the change of a pre-existing branch."""
1472
1473         return iter([])
1474
1475     def generate_delete_summary(self, push):
1476         """Called for the deletion of any type of reference."""
1477
1478         (sha1, subject) = self.old.get_summary()
1479         yield self.expand(
1480             BRIEF_SUMMARY_TEMPLATE, action='was',
1481             rev_short=sha1, text=subject,
1482             )
1483         yield '\n'
1484
1485     def get_alt_fromaddr(self):
1486         return self.environment.from_refchange
1487
1488
1489 class BranchChange(ReferenceChange):
1490     refname_type = 'branch'
1491
1492     def __init__(self, environment, refname, short_refname, old, new, rev):
1493         ReferenceChange.__init__(
1494             self, environment,
1495             refname=refname, short_refname=short_refname,
1496             old=old, new=new, rev=rev,
1497             )
1498         self.recipients = environment.get_refchange_recipients(self)
1499         self._single_revision = None
1500
1501     def send_single_combined_email(self, known_added_sha1s):
1502         if not self.environment.combine_when_single_commit:
1503             return None
1504
1505         # In the sadly-all-too-frequent usecase of people pushing only
1506         # one of their commits at a time to a repository, users feel
1507         # the reference change summary emails are noise rather than
1508         # important signal.  This is because, in this particular
1509         # usecase, there is a reference change summary email for each
1510         # new commit, and all these summaries do is point out that
1511         # there is one new commit (which can readily be inferred by
1512         # the existence of the individual revision email that is also
1513         # sent).  In such cases, our users prefer there to be a combined
1514         # reference change summary/new revision email.
1515         #
1516         # So, if the change is an update and it doesn't discard any
1517         # commits, and it adds exactly one non-merge commit (gerrit
1518         # forces a workflow where every commit is individually merged
1519         # and the git-multimail hook fired off for just this one
1520         # change), then we send a combined refchange/revision email.
1521         try:
1522             # If this change is a reference update that doesn't discard
1523             # any commits...
1524             if self.change_type != 'update':
1525                 return None
1526
1527             if read_git_lines(
1528                     ['merge-base', self.old.sha1, self.new.sha1]
1529                     ) != [self.old.sha1]:
1530                 return None
1531
1532             # Check if this update introduced exactly one non-merge
1533             # commit:
1534
1535             def split_line(line):
1536                 """Split line into (sha1, [parent,...])."""
1537
1538                 words = line.split()
1539                 return (words[0], words[1:])
1540
1541             # Get the new commits introduced by the push as a list of
1542             # (sha1, [parent,...])
1543             new_commits = [
1544                 split_line(line)
1545                 for line in read_git_lines(
1546                     [
1547                         'log', '-3', '--format=%H %P',
1548                         '%s..%s' % (self.old.sha1, self.new.sha1),
1549                         ]
1550                     )
1551                 ]
1552
1553             if not new_commits:
1554                 return None
1555
1556             # If the newest commit is a merge, save it for a later check
1557             # but otherwise ignore it
1558             merge = None
1559             tot = len(new_commits)
1560             if len(new_commits[0][1]) > 1:
1561                 merge = new_commits[0][0]
1562                 del new_commits[0]
1563
1564             # Our primary check: we can't combine if more than one commit
1565             # is introduced.  We also currently only combine if the new
1566             # commit is a non-merge commit, though it may make sense to
1567             # combine if it is a merge as well.
1568             if not (
1569                     len(new_commits) == 1 and
1570                     len(new_commits[0][1]) == 1 and
1571                     new_commits[0][0] in known_added_sha1s
1572                     ):
1573                 return None
1574
1575             # We do not want to combine revision and refchange emails if
1576             # those go to separate locations.
1577             rev = Revision(self, GitObject(new_commits[0][0]), 1, tot)
1578             if rev.recipients != self.recipients:
1579                 return None
1580
1581             # We ignored the newest commit if it was just a merge of the one
1582             # commit being introduced.  But we don't want to ignore that
1583             # merge commit it it involved conflict resolutions.  Check that.
1584             if merge and merge != read_git_output(['diff-tree', '--cc', merge]):
1585                 return None
1586
1587             # We can combine the refchange and one new revision emails
1588             # into one.  Return the Revision that a combined email should
1589             # be sent about.
1590             return rev
1591         except CommandError:
1592             # Cannot determine number of commits in old..new or new..old;
1593             # don't combine reference/revision emails:
1594             return None
1595
1596     def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):
1597         values = revision.get_values()
1598         if extra_header_values:
1599             values.update(extra_header_values)
1600         if 'subject' not in extra_header_values:
1601             values['subject'] = self.expand(COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE, **values)
1602
1603         self._single_revision = revision
1604         self._contains_diff()
1605         self.header_template = COMBINED_HEADER_TEMPLATE
1606         self.intro_template = COMBINED_INTRO_TEMPLATE
1607         self.footer_template = COMBINED_FOOTER_TEMPLATE
1608         for line in self.generate_email(push, body_filter, values):
1609             yield line
1610
1611     def generate_email_body(self, push):
1612         '''Call the appropriate body generation routine.
1613
1614         If this is a combined refchange/revision email, the special logic
1615         for handling this combined email comes from this function.  For
1616         other cases, we just use the normal handling.'''
1617
1618         # If self._single_revision isn't set; don't override
1619         if not self._single_revision:
1620             for line in super(BranchChange, self).generate_email_body(push):
1621                 yield line
1622             return
1623
1624         # This is a combined refchange/revision email; we first provide
1625         # some info from the refchange portion, and then call the revision
1626         # generate_email_body function to handle the revision portion.
1627         adds = list(generate_summaries(
1628             '--topo-order', '--reverse', '%s..%s'
1629             % (self.old.commit_sha1, self.new.commit_sha1,)
1630             ))
1631
1632         yield self.expand("The following commit(s) were added to %(refname)s by this push:\n")
1633         for (sha1, subject) in adds:
1634             yield self.expand(
1635                 BRIEF_SUMMARY_TEMPLATE, action='new',
1636                 rev_short=sha1, text=subject,
1637                 )
1638
1639         yield self._single_revision.rev.short + " is described below\n"
1640         yield '\n'
1641
1642         for line in self._single_revision.generate_email_body(push):
1643             yield line
1644
1645
1646 class AnnotatedTagChange(ReferenceChange):
1647     refname_type = 'annotated tag'
1648
1649     def __init__(self, environment, refname, short_refname, old, new, rev):
1650         ReferenceChange.__init__(
1651             self, environment,
1652             refname=refname, short_refname=short_refname,
1653             old=old, new=new, rev=rev,
1654             )
1655         self.recipients = environment.get_announce_recipients(self)
1656         self.show_shortlog = environment.announce_show_shortlog
1657
1658     ANNOTATED_TAG_FORMAT = (
1659         '%(*objectname)\n'
1660         '%(*objecttype)\n'
1661         '%(taggername)\n'
1662         '%(taggerdate)'
1663         )
1664
1665     def describe_tag(self, push):
1666         """Describe the new value of an annotated tag."""
1667
1668         # Use git for-each-ref to pull out the individual fields from
1669         # the tag
1670         [tagobject, tagtype, tagger, tagged] = read_git_lines(
1671             ['for-each-ref', '--format=%s' % (self.ANNOTATED_TAG_FORMAT,), self.refname],
1672             )
1673
1674         yield self.expand(
1675             BRIEF_SUMMARY_TEMPLATE, action='tagging',
1676             rev_short=tagobject, text='(%s)' % (tagtype,),
1677             )
1678         if tagtype == 'commit':
1679             # If the tagged object is a commit, then we assume this is a
1680             # release, and so we calculate which tag this tag is
1681             # replacing
1682             try:
1683                 prevtag = read_git_output(['describe', '--abbrev=0', '%s^' % (self.new,)])
1684             except CommandError:
1685                 prevtag = None
1686             if prevtag:
1687                 yield '  replaces  %s\n' % (prevtag,)
1688         else:
1689             prevtag = None
1690             yield '    length  %s bytes\n' % (read_git_output(['cat-file', '-s', tagobject]),)
1691
1692         yield ' tagged by  %s\n' % (tagger,)
1693         yield '        on  %s\n' % (tagged,)
1694         yield '\n'
1695
1696         # Show the content of the tag message; this might contain a
1697         # change log or release notes so is worth displaying.
1698         yield LOGBEGIN
1699         contents = list(read_git_lines(['cat-file', 'tag', self.new.sha1], keepends=True))
1700         contents = contents[contents.index('\n') + 1:]
1701         if contents and contents[-1][-1:] != '\n':
1702             contents.append('\n')
1703         for line in contents:
1704             yield line
1705
1706         if self.show_shortlog and tagtype == 'commit':
1707             # Only commit tags make sense to have rev-list operations
1708             # performed on them
1709             yield '\n'
1710             if prevtag:
1711                 # Show changes since the previous release
1712                 revlist = read_git_output(
1713                     ['rev-list', '--pretty=short', '%s..%s' % (prevtag, self.new,)],
1714                     keepends=True,
1715                     )
1716             else:
1717                 # No previous tag, show all the changes since time
1718                 # began
1719                 revlist = read_git_output(
1720                     ['rev-list', '--pretty=short', '%s' % (self.new,)],
1721                     keepends=True,
1722                     )
1723             for line in read_git_lines(['shortlog'], input=revlist, keepends=True):
1724                 yield line
1725
1726         yield LOGEND
1727         yield '\n'
1728
1729     def generate_create_summary(self, push):
1730         """Called for the creation of an annotated tag."""
1731
1732         for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1733             yield line
1734
1735         for line in self.describe_tag(push):
1736             yield line
1737
1738     def generate_update_summary(self, push):
1739         """Called for the update of an annotated tag.
1740
1741         This is probably a rare event and may not even be allowed."""
1742
1743         for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1744             yield line
1745
1746         for line in self.describe_tag(push):
1747             yield line
1748
1749     def generate_delete_summary(self, push):
1750         """Called when a non-annotated reference is updated."""
1751
1752         for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1753             yield line
1754
1755         yield self.expand('   tag was  %(oldrev_short)s\n')
1756         yield '\n'
1757
1758
1759 class NonAnnotatedTagChange(ReferenceChange):
1760     refname_type = 'tag'
1761
1762     def __init__(self, environment, refname, short_refname, old, new, rev):
1763         ReferenceChange.__init__(
1764             self, environment,
1765             refname=refname, short_refname=short_refname,
1766             old=old, new=new, rev=rev,
1767             )
1768         self.recipients = environment.get_refchange_recipients(self)
1769
1770     def generate_create_summary(self, push):
1771         """Called for the creation of an annotated tag."""
1772
1773         for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1774             yield line
1775
1776     def generate_update_summary(self, push):
1777         """Called when a non-annotated reference is updated."""
1778
1779         for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1780             yield line
1781
1782     def generate_delete_summary(self, push):
1783         """Called when a non-annotated reference is updated."""
1784
1785         for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1786             yield line
1787
1788         for line in ReferenceChange.generate_delete_summary(self, push):
1789             yield line
1790
1791
1792 class OtherReferenceChange(ReferenceChange):
1793     refname_type = 'reference'
1794
1795     def __init__(self, environment, refname, short_refname, old, new, rev):
1796         # We use the full refname as short_refname, because otherwise
1797         # the full name of the reference would not be obvious from the
1798         # text of the email.
1799         ReferenceChange.__init__(
1800             self, environment,
1801             refname=refname, short_refname=refname,
1802             old=old, new=new, rev=rev,
1803             )
1804         self.recipients = environment.get_refchange_recipients(self)
1805
1806
1807 class Mailer(object):
1808     """An object that can send emails."""
1809
1810     def send(self, lines, to_addrs):
1811         """Send an email consisting of lines.
1812
1813         lines must be an iterable over the lines constituting the
1814         header and body of the email.  to_addrs is a list of recipient
1815         addresses (can be needed even if lines already contains a
1816         "To:" field).  It can be either a string (comma-separated list
1817         of email addresses) or a Python list of individual email
1818         addresses.
1819
1820         """
1821
1822         raise NotImplementedError()
1823
1824
1825 class SendMailer(Mailer):
1826     """Send emails using 'sendmail -oi -t'."""
1827
1828     SENDMAIL_CANDIDATES = [
1829         '/usr/sbin/sendmail',
1830         '/usr/lib/sendmail',
1831         ]
1832
1833     @staticmethod
1834     def find_sendmail():
1835         for path in SendMailer.SENDMAIL_CANDIDATES:
1836             if os.access(path, os.X_OK):
1837                 return path
1838         else:
1839             raise ConfigurationException(
1840                 'No sendmail executable found.  '
1841                 'Try setting multimailhook.sendmailCommand.'
1842                 )
1843
1844     def __init__(self, command=None, envelopesender=None):
1845         """Construct a SendMailer instance.
1846
1847         command should be the command and arguments used to invoke
1848         sendmail, as a list of strings.  If an envelopesender is
1849         provided, it will also be passed to the command, via '-f
1850         envelopesender'."""
1851
1852         if command:
1853             self.command = command[:]
1854         else:
1855             self.command = [self.find_sendmail(), '-oi', '-t']
1856
1857         if envelopesender:
1858             self.command.extend(['-f', envelopesender])
1859
1860     def send(self, lines, to_addrs):
1861         try:
1862             p = subprocess.Popen(self.command, stdin=subprocess.PIPE)
1863         except OSError:
1864             sys.stderr.write(
1865                 '*** Cannot execute command: %s\n' % ' '.join(self.command) +
1866                 '*** %s\n' % sys.exc_info()[1] +
1867                 '*** Try setting multimailhook.mailer to "smtp"\n' +
1868                 '*** to send emails without using the sendmail command.\n'
1869                 )
1870             sys.exit(1)
1871         try:
1872             lines = (str_to_bytes(line) for line in lines)
1873             p.stdin.writelines(lines)
1874         except Exception:
1875             sys.stderr.write(
1876                 '*** Error while generating commit email\n'
1877                 '***  - mail sending aborted.\n'
1878                 )
1879             try:
1880                 # subprocess.terminate() is not available in Python 2.4
1881                 p.terminate()
1882             except AttributeError:
1883                 pass
1884             raise
1885         else:
1886             p.stdin.close()
1887             retcode = p.wait()
1888             if retcode:
1889                 raise CommandError(self.command, retcode)
1890
1891
1892 class SMTPMailer(Mailer):
1893     """Send emails using Python's smtplib."""
1894
1895     def __init__(self, envelopesender, smtpserver,
1896                  smtpservertimeout=10.0, smtpserverdebuglevel=0,
1897                  smtpencryption='none',
1898                  smtpuser='', smtppass='',
1899                  ):
1900         if not envelopesender:
1901             sys.stderr.write(
1902                 'fatal: git_multimail: cannot use SMTPMailer without a sender address.\n'
1903                 'please set either multimailhook.envelopeSender or user.email\n'
1904                 )
1905             sys.exit(1)
1906         if smtpencryption == 'ssl' and not (smtpuser and smtppass):
1907             raise ConfigurationException(
1908                 'Cannot use SMTPMailer with security option ssl '
1909                 'without options username and password.'
1910                 )
1911         self.envelopesender = envelopesender
1912         self.smtpserver = smtpserver
1913         self.smtpservertimeout = smtpservertimeout
1914         self.smtpserverdebuglevel = smtpserverdebuglevel
1915         self.security = smtpencryption
1916         self.username = smtpuser
1917         self.password = smtppass
1918         try:
1919             def call(klass, server, timeout):
1920                 try:
1921                     return klass(server, timeout=timeout)
1922                 except TypeError:
1923                     # Old Python versions do not have timeout= argument.
1924                     return klass(server)
1925             if self.security == 'none':
1926                 self.smtp = call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)
1927             elif self.security == 'ssl':
1928                 self.smtp = call(smtplib.SMTP_SSL, self.smtpserver, timeout=self.smtpservertimeout)
1929             elif self.security == 'tls':
1930                 if ':' not in self.smtpserver:
1931                     self.smtpserver += ':587'  # default port for TLS
1932                 self.smtp = call(smtplib.SMTP, self.smtpserver, timeout=self.smtpservertimeout)
1933                 self.smtp.ehlo()
1934                 self.smtp.starttls()
1935                 self.smtp.ehlo()
1936             else:
1937                 sys.stdout.write('*** Error: Control reached an invalid option. ***')
1938                 sys.exit(1)
1939             if self.smtpserverdebuglevel > 0:
1940                 sys.stdout.write(
1941                     "*** Setting debug on for SMTP server connection (%s) ***\n"
1942                     % self.smtpserverdebuglevel)
1943                 self.smtp.set_debuglevel(self.smtpserverdebuglevel)
1944         except Exception:
1945             sys.stderr.write(
1946                 '*** Error establishing SMTP connection to %s ***\n'
1947                 % self.smtpserver)
1948             sys.stderr.write('*** %s\n' % sys.exc_info()[1])
1949             sys.exit(1)
1950
1951     def __del__(self):
1952         if hasattr(self, 'smtp'):
1953             self.smtp.quit()
1954
1955     def send(self, lines, to_addrs):
1956         try:
1957             if self.username or self.password:
1958                 self.smtp.login(self.username, self.password)
1959             msg = ''.join(lines)
1960             # turn comma-separated list into Python list if needed.
1961             if isinstance(to_addrs, basestring):
1962                 to_addrs = [email for (name, email) in getaddresses([to_addrs])]
1963             self.smtp.sendmail(self.envelopesender, to_addrs, msg)
1964         except Exception:
1965             sys.stderr.write('*** Error sending email ***\n')
1966             sys.stderr.write('*** %s\n' % sys.exc_info()[1])
1967             self.smtp.quit()
1968             sys.exit(1)
1969
1970
1971 class OutputMailer(Mailer):
1972     """Write emails to an output stream, bracketed by lines of '=' characters.
1973
1974     This is intended for debugging purposes."""
1975
1976     SEPARATOR = '=' * 75 + '\n'
1977
1978     def __init__(self, f):
1979         self.f = f
1980
1981     def send(self, lines, to_addrs):
1982         write_str(self.f, self.SEPARATOR)
1983         for line in lines:
1984             write_str(self.f, line)
1985         write_str(self.f, self.SEPARATOR)
1986
1987
1988 def get_git_dir():
1989     """Determine GIT_DIR.
1990
1991     Determine GIT_DIR either from the GIT_DIR environment variable or
1992     from the working directory, using Git's usual rules."""
1993
1994     try:
1995         return read_git_output(['rev-parse', '--git-dir'])
1996     except CommandError:
1997         sys.stderr.write('fatal: git_multimail: not in a git directory\n')
1998         sys.exit(1)
1999
2000
2001 class Environment(object):
2002     """Describes the environment in which the push is occurring.
2003
2004     An Environment object encapsulates information about the local
2005     environment.  For example, it knows how to determine:
2006
2007     * the name of the repository to which the push occurred
2008
2009     * what user did the push
2010
2011     * what users want to be informed about various types of changes.
2012
2013     An Environment object is expected to have the following methods:
2014
2015         get_repo_shortname()
2016
2017             Return a short name for the repository, for display
2018             purposes.
2019
2020         get_repo_path()
2021
2022             Return the absolute path to the Git repository.
2023
2024         get_emailprefix()
2025
2026             Return a string that will be prefixed to every email's
2027             subject.
2028
2029         get_pusher()
2030
2031             Return the username of the person who pushed the changes.
2032             This value is used in the email body to indicate who
2033             pushed the change.
2034
2035         get_pusher_email() (may return None)
2036
2037             Return the email address of the person who pushed the
2038             changes.  The value should be a single RFC 2822 email
2039             address as a string; e.g., "Joe User <user@example.com>"
2040             if available, otherwise "user@example.com".  If set, the
2041             value is used as the Reply-To address for refchange
2042             emails.  If it is impossible to determine the pusher's
2043             email, this attribute should be set to None (in which case
2044             no Reply-To header will be output).
2045
2046         get_sender()
2047
2048             Return the address to be used as the 'From' email address
2049             in the email envelope.
2050
2051         get_fromaddr(change=None)
2052
2053             Return the 'From' email address used in the email 'From:'
2054             headers.  If the change is known when this function is
2055             called, it is passed in as the 'change' parameter.  (May
2056             be a full RFC 2822 email address like 'Joe User
2057             <user@example.com>'.)
2058
2059         get_administrator()
2060
2061             Return the name and/or email of the repository
2062             administrator.  This value is used in the footer as the
2063             person to whom requests to be removed from the
2064             notification list should be sent.  Ideally, it should
2065             include a valid email address.
2066
2067         get_reply_to_refchange()
2068         get_reply_to_commit()
2069
2070             Return the address to use in the email "Reply-To" header,
2071             as a string.  These can be an RFC 2822 email address, or
2072             None to omit the "Reply-To" header.
2073             get_reply_to_refchange() is used for refchange emails;
2074             get_reply_to_commit() is used for individual commit
2075             emails.
2076
2077         get_ref_filter_regex()
2078
2079             Return a tuple -- a compiled regex, and a boolean indicating
2080             whether the regex picks refs to include (if False, the regex
2081             matches on refs to exclude).
2082
2083         get_default_ref_ignore_regex()
2084
2085             Return a regex that should be ignored for both what emails
2086             to send and when computing what commits are considered new
2087             to the repository.  Default is "^refs/notes/".
2088
2089     They should also define the following attributes:
2090
2091         announce_show_shortlog (bool)
2092
2093             True iff announce emails should include a shortlog.
2094
2095         commit_email_format (string)
2096
2097             If "html", generate commit emails in HTML instead of plain text
2098             used by default.
2099
2100         refchange_showgraph (bool)
2101
2102             True iff refchanges emails should include a detailed graph.
2103
2104         refchange_showlog (bool)
2105
2106             True iff refchanges emails should include a detailed log.
2107
2108         diffopts (list of strings)
2109
2110             The options that should be passed to 'git diff' for the
2111             summary email.  The value should be a list of strings
2112             representing words to be passed to the command.
2113
2114         graphopts (list of strings)
2115
2116             Analogous to diffopts, but contains options passed to
2117             'git log --graph' when generating the detailed graph for
2118             a set of commits (see refchange_showgraph)
2119
2120         logopts (list of strings)
2121
2122             Analogous to diffopts, but contains options passed to
2123             'git log' when generating the detailed log for a set of
2124             commits (see refchange_showlog)
2125
2126         commitlogopts (list of strings)
2127
2128             The options that should be passed to 'git log' for each
2129             commit mail.  The value should be a list of strings
2130             representing words to be passed to the command.
2131
2132         date_substitute (string)
2133
2134             String to be used in substitution for 'Date:' at start of
2135             line in the output of 'git log'.
2136
2137         quiet (bool)
2138             On success do not write to stderr
2139
2140         stdout (bool)
2141             Write email to stdout rather than emailing. Useful for debugging
2142
2143         combine_when_single_commit (bool)
2144
2145             True if a combined email should be produced when a single
2146             new commit is pushed to a branch, False otherwise.
2147
2148         from_refchange, from_commit (strings)
2149
2150             Addresses to use for the From: field for refchange emails
2151             and commit emails respectively.  Set from
2152             multimailhook.fromRefchange and multimailhook.fromCommit
2153             by ConfigEnvironmentMixin.
2154
2155     """
2156
2157     REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$')
2158
2159     def __init__(self, osenv=None):
2160         self.osenv = osenv or os.environ
2161         self.announce_show_shortlog = False
2162         self.commit_email_format = "text"
2163         self.maxcommitemails = 500
2164         self.diffopts = ['--stat', '--summary', '--find-copies-harder']
2165         self.graphopts = ['--oneline', '--decorate']
2166         self.logopts = []
2167         self.refchange_showgraph = False
2168         self.refchange_showlog = False
2169         self.commitlogopts = ['-C', '--stat', '-p', '--cc']
2170         self.date_substitute = 'AuthorDate: '
2171         self.quiet = False
2172         self.stdout = False
2173         self.combine_when_single_commit = True
2174
2175         self.COMPUTED_KEYS = [
2176             'administrator',
2177             'charset',
2178             'emailprefix',
2179             'pusher',
2180             'pusher_email',
2181             'repo_path',
2182             'repo_shortname',
2183             'sender',
2184             ]
2185
2186         self._values = None
2187
2188     def get_repo_shortname(self):
2189         """Use the last part of the repo path, with ".git" stripped off if present."""
2190
2191         basename = os.path.basename(os.path.abspath(self.get_repo_path()))
2192         m = self.REPO_NAME_RE.match(basename)
2193         if m:
2194             return m.group('name')
2195         else:
2196             return basename
2197
2198     def get_pusher(self):
2199         raise NotImplementedError()
2200
2201     def get_pusher_email(self):
2202         return None
2203
2204     def get_fromaddr(self, change=None):
2205         config = Config('user')
2206         fromname = config.get('name', default='')
2207         fromemail = config.get('email', default='')
2208         if fromemail:
2209             return formataddr([fromname, fromemail])
2210         return self.get_sender()
2211
2212     def get_administrator(self):
2213         return 'the administrator of this repository'
2214
2215     def get_emailprefix(self):
2216         return ''
2217
2218     def get_repo_path(self):
2219         if read_git_output(['rev-parse', '--is-bare-repository']) == 'true':
2220             path = get_git_dir()
2221         else:
2222             path = read_git_output(['rev-parse', '--show-toplevel'])
2223         return os.path.abspath(path)
2224
2225     def get_charset(self):
2226         return CHARSET
2227
2228     def get_values(self):
2229         """Return a dictionary {keyword: expansion} for this Environment.
2230
2231         This method is called by Change._compute_values().  The keys
2232         in the returned dictionary are available to be used in any of
2233         the templates.  The dictionary is created by calling
2234         self.get_NAME() for each of the attributes named in
2235         COMPUTED_KEYS and recording those that do not return None.
2236         The return value is always a new dictionary."""
2237
2238         if self._values is None:
2239             values = {}
2240
2241             for key in self.COMPUTED_KEYS:
2242                 value = getattr(self, 'get_%s' % (key,))()
2243                 if value is not None:
2244                     values[key] = value
2245
2246             self._values = values
2247
2248         return self._values.copy()
2249
2250     def get_refchange_recipients(self, refchange):
2251         """Return the recipients for notifications about refchange.
2252
2253         Return the list of email addresses to which notifications
2254         about the specified ReferenceChange should be sent."""
2255
2256         raise NotImplementedError()
2257
2258     def get_announce_recipients(self, annotated_tag_change):
2259         """Return the recipients for notifications about annotated_tag_change.
2260
2261         Return the list of email addresses to which notifications
2262         about the specified AnnotatedTagChange should be sent."""
2263
2264         raise NotImplementedError()
2265
2266     def get_reply_to_refchange(self, refchange):
2267         return self.get_pusher_email()
2268
2269     def get_revision_recipients(self, revision):
2270         """Return the recipients for messages about revision.
2271
2272         Return the list of email addresses to which notifications
2273         about the specified Revision should be sent.  This method
2274         could be overridden, for example, to take into account the
2275         contents of the revision when deciding whom to notify about
2276         it.  For example, there could be a scheme for users to express
2277         interest in particular files or subdirectories, and only
2278         receive notification emails for revisions that affecting those
2279         files."""
2280
2281         raise NotImplementedError()
2282
2283     def get_reply_to_commit(self, revision):
2284         return revision.author
2285
2286     def get_default_ref_ignore_regex(self):
2287         # The commit messages of git notes are essentially meaningless
2288         # and "filenames" in git notes commits are an implementational
2289         # detail that might surprise users at first.  As such, we
2290         # would need a completely different method for handling emails
2291         # of git notes in order for them to be of benefit for users,
2292         # which we simply do not have right now.
2293         return "^refs/notes/"
2294
2295     def filter_body(self, lines):
2296         """Filter the lines intended for an email body.
2297
2298         lines is an iterable over the lines that would go into the
2299         email body.  Filter it (e.g., limit the number of lines, the
2300         line length, character set, etc.), returning another iterable.
2301         See FilterLinesEnvironmentMixin and MaxlinesEnvironmentMixin
2302         for classes implementing this functionality."""
2303
2304         return lines
2305
2306     def log_msg(self, msg):
2307         """Write the string msg on a log file or on stderr.
2308
2309         Sends the text to stderr by default, override to change the behavior."""
2310         write_str(sys.stderr, msg)
2311
2312     def log_warning(self, msg):
2313         """Write the string msg on a log file or on stderr.
2314
2315         Sends the text to stderr by default, override to change the behavior."""
2316         write_str(sys.stderr, msg)
2317
2318     def log_error(self, msg):
2319         """Write the string msg on a log file or on stderr.
2320
2321         Sends the text to stderr by default, override to change the behavior."""
2322         write_str(sys.stderr, msg)
2323
2324
2325 class ConfigEnvironmentMixin(Environment):
2326     """A mixin that sets self.config to its constructor's config argument.
2327
2328     This class's constructor consumes the "config" argument.
2329
2330     Mixins that need to inspect the config should inherit from this
2331     class (1) to make sure that "config" is still in the constructor
2332     arguments with its own constructor runs and/or (2) to be sure that
2333     self.config is set after construction."""
2334
2335     def __init__(self, config, **kw):
2336         super(ConfigEnvironmentMixin, self).__init__(**kw)
2337         self.config = config
2338
2339
2340 class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):
2341     """An Environment that reads most of its information from "git config"."""
2342
2343     @staticmethod
2344     def forbid_field_values(name, value, forbidden):
2345         for forbidden_val in forbidden:
2346             if value is not None and value.lower() == forbidden:
2347                 raise ConfigurationException(
2348                     '"%s" is not an allowed setting for %s' % (value, name)
2349                     )
2350
2351     def __init__(self, config, **kw):
2352         super(ConfigOptionsEnvironmentMixin, self).__init__(
2353             config=config, **kw
2354             )
2355
2356         for var, cfg in (
2357                 ('announce_show_shortlog', 'announceshortlog'),
2358                 ('refchange_showgraph', 'refchangeShowGraph'),
2359                 ('refchange_showlog', 'refchangeshowlog'),
2360                 ('quiet', 'quiet'),
2361                 ('stdout', 'stdout'),
2362                 ):
2363             val = config.get_bool(cfg)
2364             if val is not None:
2365                 setattr(self, var, val)
2366
2367         commit_email_format = config.get('commitEmailFormat')
2368         if commit_email_format is not None:
2369             if commit_email_format != "html" and commit_email_format != "text":
2370                 self.log_warning(
2371                     '*** Unknown value for multimailhook.commitEmailFormat: %s\n' %
2372                     commit_email_format +
2373                     '*** Expected either "text" or "html".  Ignoring.\n'
2374                     )
2375             else:
2376                 self.commit_email_format = commit_email_format
2377
2378         maxcommitemails = config.get('maxcommitemails')
2379         if maxcommitemails is not None:
2380             try:
2381                 self.maxcommitemails = int(maxcommitemails)
2382             except ValueError:
2383                 self.log_warning(
2384                     '*** Malformed value for multimailhook.maxCommitEmails: %s\n'
2385                     % maxcommitemails +
2386                     '*** Expected a number.  Ignoring.\n'
2387                     )
2388
2389         diffopts = config.get('diffopts')
2390         if diffopts is not None:
2391             self.diffopts = shlex.split(diffopts)
2392
2393         graphopts = config.get('graphOpts')
2394         if graphopts is not None:
2395             self.graphopts = shlex.split(graphopts)
2396
2397         logopts = config.get('logopts')
2398         if logopts is not None:
2399             self.logopts = shlex.split(logopts)
2400
2401         commitlogopts = config.get('commitlogopts')
2402         if commitlogopts is not None:
2403             self.commitlogopts = shlex.split(commitlogopts)
2404
2405         date_substitute = config.get('dateSubstitute')
2406         if date_substitute == 'none':
2407             self.date_substitute = None
2408         elif date_substitute is not None:
2409             self.date_substitute = date_substitute
2410
2411         reply_to = config.get('replyTo')
2412         self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to)
2413         self.forbid_field_values('replyToRefchange',
2414                                  self.__reply_to_refchange,
2415                                  ['author'])
2416         self.__reply_to_commit = config.get('replyToCommit', default=reply_to)
2417
2418         from_addr = self.config.get('from')
2419         self.from_refchange = config.get('fromRefchange')
2420         self.forbid_field_values('fromRefchange',
2421                                  self.from_refchange,
2422                                  ['author', 'none'])
2423         self.from_commit = config.get('fromCommit')
2424         self.forbid_field_values('fromCommit',
2425                                  self.from_commit,
2426                                  ['none'])
2427
2428         combine = config.get_bool('combineWhenSingleCommit')
2429         if combine is not None:
2430             self.combine_when_single_commit = combine
2431
2432     def get_administrator(self):
2433         return (
2434             self.config.get('administrator') or
2435             self.get_sender() or
2436             super(ConfigOptionsEnvironmentMixin, self).get_administrator()
2437             )
2438
2439     def get_repo_shortname(self):
2440         return (
2441             self.config.get('reponame') or
2442             super(ConfigOptionsEnvironmentMixin, self).get_repo_shortname()
2443             )
2444
2445     def get_emailprefix(self):
2446         emailprefix = self.config.get('emailprefix')
2447         if emailprefix is not None:
2448             emailprefix = emailprefix.strip()
2449             if emailprefix:
2450                 return emailprefix + ' '
2451             else:
2452                 return ''
2453         else:
2454             return '[%s] ' % (self.get_repo_shortname(),)
2455
2456     def get_sender(self):
2457         return self.config.get('envelopesender')
2458
2459     def process_addr(self, addr, change):
2460         if addr.lower() == 'author':
2461             if hasattr(change, 'author'):
2462                 return change.author
2463             else:
2464                 return None
2465         elif addr.lower() == 'pusher':
2466             return self.get_pusher_email()
2467         elif addr.lower() == 'none':
2468             return None
2469         else:
2470             return addr
2471
2472     def get_fromaddr(self, change=None):
2473         fromaddr = self.config.get('from')
2474         if change:
2475             alt_fromaddr = change.get_alt_fromaddr()
2476             if alt_fromaddr:
2477                 fromaddr = alt_fromaddr
2478         if fromaddr:
2479             fromaddr = self.process_addr(fromaddr, change)
2480         if fromaddr:
2481             return fromaddr
2482         return super(ConfigOptionsEnvironmentMixin, self).get_fromaddr(change)
2483
2484     def get_reply_to_refchange(self, refchange):
2485         if self.__reply_to_refchange is None:
2486             return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_refchange(refchange)
2487         else:
2488             return self.process_addr(self.__reply_to_refchange, refchange)
2489
2490     def get_reply_to_commit(self, revision):
2491         if self.__reply_to_commit is None:
2492             return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_commit(revision)
2493         else:
2494             return self.process_addr(self.__reply_to_commit, revision)
2495
2496     def get_scancommitforcc(self):
2497         return self.config.get('scancommitforcc')
2498
2499
2500 class FilterLinesEnvironmentMixin(Environment):
2501     """Handle encoding and maximum line length of body lines.
2502
2503         emailmaxlinelength (int or None)
2504
2505             The maximum length of any single line in the email body.
2506             Longer lines are truncated at that length with ' [...]'
2507             appended.
2508
2509         strict_utf8 (bool)
2510
2511             If this field is set to True, then the email body text is
2512             expected to be UTF-8.  Any invalid characters are
2513             converted to U+FFFD, the Unicode replacement character
2514             (encoded as UTF-8, of course).
2515
2516     """
2517
2518     def __init__(self, strict_utf8=True, emailmaxlinelength=500, **kw):
2519         super(FilterLinesEnvironmentMixin, self).__init__(**kw)
2520         self.__strict_utf8 = strict_utf8
2521         self.__emailmaxlinelength = emailmaxlinelength
2522
2523     def filter_body(self, lines):
2524         lines = super(FilterLinesEnvironmentMixin, self).filter_body(lines)
2525         if self.__strict_utf8:
2526             if not PYTHON3:
2527                 lines = (line.decode(ENCODING, 'replace') for line in lines)
2528             # Limit the line length in Unicode-space to avoid
2529             # splitting characters:
2530             if self.__emailmaxlinelength:
2531                 lines = limit_linelength(lines, self.__emailmaxlinelength)
2532             if not PYTHON3:
2533                 lines = (line.encode(ENCODING, 'replace') for line in lines)
2534         elif self.__emailmaxlinelength:
2535             lines = limit_linelength(lines, self.__emailmaxlinelength)
2536
2537         return lines
2538
2539
2540 class ConfigFilterLinesEnvironmentMixin(
2541         ConfigEnvironmentMixin,
2542         FilterLinesEnvironmentMixin,
2543         ):
2544     """Handle encoding and maximum line length based on config."""
2545
2546     def __init__(self, config, **kw):
2547         strict_utf8 = config.get_bool('emailstrictutf8', default=None)
2548         if strict_utf8 is not None:
2549             kw['strict_utf8'] = strict_utf8
2550
2551         emailmaxlinelength = config.get('emailmaxlinelength')
2552         if emailmaxlinelength is not None:
2553             kw['emailmaxlinelength'] = int(emailmaxlinelength)
2554
2555         super(ConfigFilterLinesEnvironmentMixin, self).__init__(
2556             config=config, **kw
2557             )
2558
2559
2560 class MaxlinesEnvironmentMixin(Environment):
2561     """Limit the email body to a specified number of lines."""
2562
2563     def __init__(self, emailmaxlines, **kw):
2564         super(MaxlinesEnvironmentMixin, self).__init__(**kw)
2565         self.__emailmaxlines = emailmaxlines
2566
2567     def filter_body(self, lines):
2568         lines = super(MaxlinesEnvironmentMixin, self).filter_body(lines)
2569         if self.__emailmaxlines:
2570             lines = limit_lines(lines, self.__emailmaxlines)
2571         return lines
2572
2573
2574 class ConfigMaxlinesEnvironmentMixin(
2575         ConfigEnvironmentMixin,
2576         MaxlinesEnvironmentMixin,
2577         ):
2578     """Limit the email body to the number of lines specified in config."""
2579
2580     def __init__(self, config, **kw):
2581         emailmaxlines = int(config.get('emailmaxlines', default='0'))
2582         super(ConfigMaxlinesEnvironmentMixin, self).__init__(
2583             config=config,
2584             emailmaxlines=emailmaxlines,
2585             **kw
2586             )
2587
2588
2589 class FQDNEnvironmentMixin(Environment):
2590     """A mixin that sets the host's FQDN to its constructor argument."""
2591
2592     def __init__(self, fqdn, **kw):
2593         super(FQDNEnvironmentMixin, self).__init__(**kw)
2594         self.COMPUTED_KEYS += ['fqdn']
2595         self.__fqdn = fqdn
2596
2597     def get_fqdn(self):
2598         """Return the fully-qualified domain name for this host.
2599
2600         Return None if it is unavailable or unwanted."""
2601
2602         return self.__fqdn
2603
2604
2605 class ConfigFQDNEnvironmentMixin(
2606         ConfigEnvironmentMixin,
2607         FQDNEnvironmentMixin,
2608         ):
2609     """Read the FQDN from the config."""
2610
2611     def __init__(self, config, **kw):
2612         fqdn = config.get('fqdn')
2613         super(ConfigFQDNEnvironmentMixin, self).__init__(
2614             config=config,
2615             fqdn=fqdn,
2616             **kw
2617             )
2618
2619
2620 class ComputeFQDNEnvironmentMixin(FQDNEnvironmentMixin):
2621     """Get the FQDN by calling socket.getfqdn()."""
2622
2623     def __init__(self, **kw):
2624         super(ComputeFQDNEnvironmentMixin, self).__init__(
2625             fqdn=socket.getfqdn(),
2626             **kw
2627             )
2628
2629
2630 class PusherDomainEnvironmentMixin(ConfigEnvironmentMixin):
2631     """Deduce pusher_email from pusher by appending an emaildomain."""
2632
2633     def __init__(self, **kw):
2634         super(PusherDomainEnvironmentMixin, self).__init__(**kw)
2635         self.__emaildomain = self.config.get('emaildomain')
2636
2637     def get_pusher_email(self):
2638         if self.__emaildomain:
2639             # Derive the pusher's full email address in the default way:
2640             return '%s@%s' % (self.get_pusher(), self.__emaildomain)
2641         else:
2642             return super(PusherDomainEnvironmentMixin, self).get_pusher_email()
2643
2644
2645 class StaticRecipientsEnvironmentMixin(Environment):
2646     """Set recipients statically based on constructor parameters."""
2647
2648     def __init__(
2649             self,
2650             refchange_recipients, announce_recipients, revision_recipients, scancommitforcc,
2651             **kw
2652             ):
2653         super(StaticRecipientsEnvironmentMixin, self).__init__(**kw)
2654
2655         # The recipients for various types of notification emails, as
2656         # RFC 2822 email addresses separated by commas (or the empty
2657         # string if no recipients are configured).  Although there is
2658         # a mechanism to choose the recipient lists based on on the
2659         # actual *contents* of the change being reported, we only
2660         # choose based on the *type* of the change.  Therefore we can
2661         # compute them once and for all:
2662         if not (refchange_recipients or
2663                 announce_recipients or
2664                 revision_recipients or
2665                 scancommitforcc):
2666             raise ConfigurationException('No email recipients configured!')
2667         self.__refchange_recipients = refchange_recipients
2668         self.__announce_recipients = announce_recipients
2669         self.__revision_recipients = revision_recipients
2670
2671     def get_refchange_recipients(self, refchange):
2672         return self.__refchange_recipients
2673
2674     def get_announce_recipients(self, annotated_tag_change):
2675         return self.__announce_recipients
2676
2677     def get_revision_recipients(self, revision):
2678         return self.__revision_recipients
2679
2680
2681 class ConfigRecipientsEnvironmentMixin(
2682         ConfigEnvironmentMixin,
2683         StaticRecipientsEnvironmentMixin
2684         ):
2685     """Determine recipients statically based on config."""
2686
2687     def __init__(self, config, **kw):
2688         super(ConfigRecipientsEnvironmentMixin, self).__init__(
2689             config=config,
2690             refchange_recipients=self._get_recipients(
2691                 config, 'refchangelist', 'mailinglist',
2692                 ),
2693             announce_recipients=self._get_recipients(
2694                 config, 'announcelist', 'refchangelist', 'mailinglist',
2695                 ),
2696             revision_recipients=self._get_recipients(
2697                 config, 'commitlist', 'mailinglist',
2698                 ),
2699             scancommitforcc=config.get('scancommitforcc'),
2700             **kw
2701             )
2702
2703     def _get_recipients(self, config, *names):
2704         """Return the recipients for a particular type of message.
2705
2706         Return the list of email addresses to which a particular type
2707         of notification email should be sent, by looking at the config
2708         value for "multimailhook.$name" for each of names.  Use the
2709         value from the first name that is configured.  The return
2710         value is a (possibly empty) string containing RFC 2822 email
2711         addresses separated by commas.  If no configuration could be
2712         found, raise a ConfigurationException."""
2713
2714         for name in names:
2715             lines = config.get_all(name)
2716             if lines is not None:
2717                 lines = [line.strip() for line in lines]
2718                 # Single "none" is a special value equivalen to empty string.
2719                 if lines == ['none']:
2720                     lines = ['']
2721                 return ', '.join(lines)
2722         else:
2723             return ''
2724
2725
2726 class StaticRefFilterEnvironmentMixin(Environment):
2727     """Set branch filter statically based on constructor parameters."""
2728
2729     def __init__(self, ref_filter_incl_regex, ref_filter_excl_regex,
2730                  ref_filter_do_send_regex, ref_filter_dont_send_regex,
2731                  **kw):
2732         super(StaticRefFilterEnvironmentMixin, self).__init__(**kw)
2733
2734         if ref_filter_incl_regex and ref_filter_excl_regex:
2735             raise ConfigurationException(
2736                 "Cannot specify both a ref inclusion and exclusion regex.")
2737         self.__is_inclusion_filter = bool(ref_filter_incl_regex)
2738         default_exclude = self.get_default_ref_ignore_regex()
2739         if ref_filter_incl_regex:
2740             ref_filter_regex = ref_filter_incl_regex
2741         elif ref_filter_excl_regex:
2742             ref_filter_regex = ref_filter_excl_regex + '|' + default_exclude
2743         else:
2744             ref_filter_regex = default_exclude
2745         try:
2746             self.__compiled_regex = re.compile(ref_filter_regex)
2747         except Exception:
2748             raise ConfigurationException(
2749                 'Invalid Ref Filter Regex "%s": %s' % (ref_filter_regex, sys.exc_info()[1]))
2750
2751         if ref_filter_do_send_regex and ref_filter_dont_send_regex:
2752             raise ConfigurationException(
2753                 "Cannot specify both a ref doSend and dontSend regex.")
2754         if ref_filter_do_send_regex or ref_filter_dont_send_regex:
2755             self.__is_do_send_filter = bool(ref_filter_do_send_regex)
2756             if ref_filter_incl_regex:
2757                 ref_filter_send_regex = ref_filter_incl_regex
2758             elif ref_filter_excl_regex:
2759                 ref_filter_send_regex = ref_filter_excl_regex
2760             else:
2761                 ref_filter_send_regex = '.*'
2762                 self.__is_do_send_filter = True
2763             try:
2764                 self.__send_compiled_regex = re.compile(ref_filter_send_regex)
2765             except Exception:
2766                 raise ConfigurationException(
2767                     'Invalid Ref Filter Regex "%s": %s' %
2768                     (ref_filter_send_regex, sys.exc_info()[1]))
2769         else:
2770             self.__send_compiled_regex = self.__compiled_regex
2771             self.__is_do_send_filter = self.__is_inclusion_filter
2772
2773     def get_ref_filter_regex(self, send_filter=False):
2774         if send_filter:
2775             return self.__send_compiled_regex, self.__is_do_send_filter
2776         else:
2777             return self.__compiled_regex, self.__is_inclusion_filter
2778
2779
2780 class ConfigRefFilterEnvironmentMixin(
2781         ConfigEnvironmentMixin,
2782         StaticRefFilterEnvironmentMixin
2783         ):
2784     """Determine branch filtering statically based on config."""
2785
2786     def _get_regex(self, config, key):
2787         """Get a list of whitespace-separated regex. The refFilter* config
2788         variables are multivalued (hence the use of get_all), and we
2789         allow each entry to be a whitespace-separated list (hence the
2790         split on each line). The whole thing is glued into a single regex."""
2791         values = config.get_all(key)
2792         if values is None:
2793             return values
2794         items = []
2795         for line in values:
2796             for i in line.split():
2797                 items.append(i)
2798         if items == []:
2799             return None
2800         return '|'.join(items)
2801
2802     def __init__(self, config, **kw):
2803         super(ConfigRefFilterEnvironmentMixin, self).__init__(
2804             config=config,
2805             ref_filter_incl_regex=self._get_regex(config, 'refFilterInclusionRegex'),
2806             ref_filter_excl_regex=self._get_regex(config, 'refFilterExclusionRegex'),
2807             ref_filter_do_send_regex=self._get_regex(config, 'refFilterDoSendRegex'),
2808             ref_filter_dont_send_regex=self._get_regex(config, 'refFilterDontSendRegex'),
2809             **kw
2810             )
2811
2812
2813 class ProjectdescEnvironmentMixin(Environment):
2814     """Make a "projectdesc" value available for templates.
2815
2816     By default, it is set to the first line of $GIT_DIR/description
2817     (if that file is present and appears to be set meaningfully)."""
2818
2819     def __init__(self, **kw):
2820         super(ProjectdescEnvironmentMixin, self).__init__(**kw)
2821         self.COMPUTED_KEYS += ['projectdesc']
2822
2823     def get_projectdesc(self):
2824         """Return a one-line descripition of the project."""
2825
2826         git_dir = get_git_dir()
2827         try:
2828             projectdesc = open(os.path.join(git_dir, 'description')).readline().strip()
2829             if projectdesc and not projectdesc.startswith('Unnamed repository'):
2830                 return projectdesc
2831         except IOError:
2832             pass
2833
2834         return 'UNNAMED PROJECT'
2835
2836
2837 class GenericEnvironmentMixin(Environment):
2838     def get_pusher(self):
2839         return self.osenv.get('USER', self.osenv.get('USERNAME', 'unknown user'))
2840
2841
2842 class GenericEnvironment(
2843         ProjectdescEnvironmentMixin,
2844         ConfigMaxlinesEnvironmentMixin,
2845         ComputeFQDNEnvironmentMixin,
2846         ConfigFilterLinesEnvironmentMixin,
2847         ConfigRecipientsEnvironmentMixin,
2848         ConfigRefFilterEnvironmentMixin,
2849         PusherDomainEnvironmentMixin,
2850         ConfigOptionsEnvironmentMixin,
2851         GenericEnvironmentMixin,
2852         Environment,
2853         ):
2854     pass
2855
2856
2857 class GitoliteEnvironmentMixin(Environment):
2858     def get_repo_shortname(self):
2859         # The gitolite environment variable $GL_REPO is a pretty good
2860         # repo_shortname (though it's probably not as good as a value
2861         # the user might have explicitly put in his config).
2862         return (
2863             self.osenv.get('GL_REPO', None) or
2864             super(GitoliteEnvironmentMixin, self).get_repo_shortname()
2865             )
2866
2867     def get_pusher(self):
2868         return self.osenv.get('GL_USER', 'unknown user')
2869
2870     def get_fromaddr(self, change=None):
2871         GL_USER = self.osenv.get('GL_USER')
2872         if GL_USER is not None:
2873             # Find the path to gitolite.conf.  Note that gitolite v3
2874             # did away with the GL_ADMINDIR and GL_CONF environment
2875             # variables (they are now hard-coded).
2876             GL_ADMINDIR = self.osenv.get(
2877                 'GL_ADMINDIR',
2878                 os.path.expanduser(os.path.join('~', '.gitolite')))
2879             GL_CONF = self.osenv.get(
2880                 'GL_CONF',
2881                 os.path.join(GL_ADMINDIR, 'conf', 'gitolite.conf'))
2882             if os.path.isfile(GL_CONF):
2883                 f = open(GL_CONF, 'rU')
2884                 try:
2885                     in_user_emails_section = False
2886                     re_template = r'^\s*#\s*%s\s*$'
2887                     re_begin, re_user, re_end = (
2888                         re.compile(re_template % x)
2889                         for x in (
2890                             r'BEGIN\s+USER\s+EMAILS',
2891                             re.escape(GL_USER) + r'\s+(.*)',
2892                             r'END\s+USER\s+EMAILS',
2893                             ))
2894                     for l in f:
2895                         l = l.rstrip('\n')
2896                         if not in_user_emails_section:
2897                             if re_begin.match(l):
2898                                 in_user_emails_section = True
2899                             continue
2900                         if re_end.match(l):
2901                             break
2902                         m = re_user.match(l)
2903                         if m:
2904                             return m.group(1)
2905                 finally:
2906                     f.close()
2907         return super(GitoliteEnvironmentMixin, self).get_fromaddr(change)
2908
2909
2910 class IncrementalDateTime(object):
2911     """Simple wrapper to give incremental date/times.
2912
2913     Each call will result in a date/time a second later than the
2914     previous call.  This can be used to falsify email headers, to
2915     increase the likelihood that email clients sort the emails
2916     correctly."""
2917
2918     def __init__(self):
2919         self.time = time.time()
2920         self.next = self.__next__  # Python 2 backward compatibility
2921
2922     def __next__(self):
2923         formatted = formatdate(self.time, True)
2924         self.time += 1
2925         return formatted
2926
2927
2928 class GitoliteEnvironment(
2929         ProjectdescEnvironmentMixin,
2930         ConfigMaxlinesEnvironmentMixin,
2931         ComputeFQDNEnvironmentMixin,
2932         ConfigFilterLinesEnvironmentMixin,
2933         ConfigRecipientsEnvironmentMixin,
2934         ConfigRefFilterEnvironmentMixin,
2935         PusherDomainEnvironmentMixin,
2936         ConfigOptionsEnvironmentMixin,
2937         GitoliteEnvironmentMixin,
2938         Environment,
2939         ):
2940     pass
2941
2942
2943 class StashEnvironmentMixin(Environment):
2944     def __init__(self, user=None, repo=None, **kw):
2945         super(StashEnvironmentMixin, self).__init__(**kw)
2946         self.__user = user
2947         self.__repo = repo
2948
2949     def get_repo_shortname(self):
2950         return self.__repo
2951
2952     def get_pusher(self):
2953         return re.match('(.*?)\s*<', self.__user).group(1)
2954
2955     def get_pusher_email(self):
2956         return self.__user
2957
2958     def get_fromaddr(self, change=None):
2959         return self.__user
2960
2961
2962 class StashEnvironment(
2963         StashEnvironmentMixin,
2964         ProjectdescEnvironmentMixin,
2965         ConfigMaxlinesEnvironmentMixin,
2966         ComputeFQDNEnvironmentMixin,
2967         ConfigFilterLinesEnvironmentMixin,
2968         ConfigRecipientsEnvironmentMixin,
2969         ConfigRefFilterEnvironmentMixin,
2970         PusherDomainEnvironmentMixin,
2971         ConfigOptionsEnvironmentMixin,
2972         Environment,
2973         ):
2974     pass
2975
2976
2977 class GerritEnvironmentMixin(Environment):
2978     def __init__(self, project=None, submitter=None, update_method=None, **kw):
2979         super(GerritEnvironmentMixin, self).__init__(**kw)
2980         self.__project = project
2981         self.__submitter = submitter
2982         self.__update_method = update_method
2983         "Make an 'update_method' value available for templates."
2984         self.COMPUTED_KEYS += ['update_method']
2985
2986     def get_repo_shortname(self):
2987         return self.__project
2988
2989     def get_pusher(self):
2990         if self.__submitter:
2991             if self.__submitter.find('<') != -1:
2992                 # Submitter has a configured email, we transformed
2993                 # __submitter into an RFC 2822 string already.
2994                 return re.match('(.*?)\s*<', self.__submitter).group(1)
2995             else:
2996                 # Submitter has no configured email, it's just his name.
2997                 return self.__submitter
2998         else:
2999             # If we arrive here, this means someone pushed "Submit" from
3000             # the gerrit web UI for the CR (or used one of the programmatic
3001             # APIs to do the same, such as gerrit review) and the
3002             # merge/push was done by the Gerrit user.  It was technically
3003             # triggered by someone else, but sadly we have no way of
3004             # determining who that someone else is at this point.
3005             return 'Gerrit'  # 'unknown user'?
3006
3007     def get_pusher_email(self):
3008         if self.__submitter:
3009             return self.__submitter
3010         else:
3011             return super(GerritEnvironmentMixin, self).get_pusher_email()
3012
3013     def get_fromaddr(self, change=None):
3014         if self.__submitter and self.__submitter.find('<') != -1:
3015             return self.__submitter
3016         else:
3017             return super(GerritEnvironmentMixin, self).get_fromaddr(change)
3018
3019     def get_default_ref_ignore_regex(self):
3020         default = super(GerritEnvironmentMixin, self).get_default_ref_ignore_regex()
3021         return default + '|^refs/changes/|^refs/cache-automerge/|^refs/meta/'
3022
3023     def get_revision_recipients(self, revision):
3024         # Merge commits created by Gerrit when users hit "Submit this patchset"
3025         # in the Web UI (or do equivalently with REST APIs or the gerrit review
3026         # command) are not something users want to see an individual email for.
3027         # Filter them out.
3028         committer = read_git_output(['log', '--no-walk', '--format=%cN',
3029                                      revision.rev.sha1])
3030         if committer == 'Gerrit Code Review':
3031             return []
3032         else:
3033             return super(GerritEnvironmentMixin, self).get_revision_recipients(revision)
3034
3035     def get_update_method(self):
3036         return self.__update_method
3037
3038
3039 class GerritEnvironment(
3040         GerritEnvironmentMixin,
3041         ProjectdescEnvironmentMixin,
3042         ConfigMaxlinesEnvironmentMixin,
3043         ComputeFQDNEnvironmentMixin,
3044         ConfigFilterLinesEnvironmentMixin,
3045         ConfigRecipientsEnvironmentMixin,
3046         ConfigRefFilterEnvironmentMixin,
3047         PusherDomainEnvironmentMixin,
3048         ConfigOptionsEnvironmentMixin,
3049         Environment,
3050         ):
3051     pass
3052
3053
3054 class Push(object):
3055     """Represent an entire push (i.e., a group of ReferenceChanges).
3056
3057     It is easy to figure out what commits were added to a *branch* by
3058     a Reference change:
3059
3060         git rev-list change.old..change.new
3061
3062     or removed from a *branch*:
3063
3064         git rev-list change.new..change.old
3065
3066     But it is not quite so trivial to determine which entirely new
3067     commits were added to the *repository* by a push and which old
3068     commits were discarded by a push.  A big part of the job of this
3069     class is to figure out these things, and to make sure that new
3070     commits are only detailed once even if they were added to multiple
3071     references.
3072
3073     The first step is to determine the "other" references--those
3074     unaffected by the current push.  They are computed by listing all
3075     references then removing any affected by this push.  The results
3076     are stored in Push._other_ref_sha1s.
3077
3078     The commits contained in the repository before this push were
3079
3080         git rev-list other1 other2 other3 ... change1.old change2.old ...
3081
3082     Where "changeN.old" is the old value of one of the references
3083     affected by this push.
3084
3085     The commits contained in the repository after this push are
3086
3087         git rev-list other1 other2 other3 ... change1.new change2.new ...
3088
3089     The commits added by this push are the difference between these
3090     two sets, which can be written
3091
3092         git rev-list \
3093             ^other1 ^other2 ... \
3094             ^change1.old ^change2.old ... \
3095             change1.new change2.new ...
3096
3097     The commits removed by this push can be computed by
3098
3099         git rev-list \
3100             ^other1 ^other2 ... \
3101             ^change1.new ^change2.new ... \
3102             change1.old change2.old ...
3103
3104     The last point is that it is possible that other pushes are
3105     occurring simultaneously to this one, so reference values can
3106     change at any time.  It is impossible to eliminate all race
3107     conditions, but we reduce the window of time during which problems
3108     can occur by translating reference names to SHA1s as soon as
3109     possible and working with SHA1s thereafter (because SHA1s are
3110     immutable)."""
3111
3112     # A map {(changeclass, changetype): integer} specifying the order
3113     # that reference changes will be processed if multiple reference
3114     # changes are included in a single push.  The order is significant
3115     # mostly because new commit notifications are threaded together
3116     # with the first reference change that includes the commit.  The
3117     # following order thus causes commits to be grouped with branch
3118     # changes (as opposed to tag changes) if possible.
3119     SORT_ORDER = dict(
3120         (value, i) for (i, value) in enumerate([
3121             (BranchChange, 'update'),
3122             (BranchChange, 'create'),
3123             (AnnotatedTagChange, 'update'),
3124             (AnnotatedTagChange, 'create'),
3125             (NonAnnotatedTagChange, 'update'),
3126             (NonAnnotatedTagChange, 'create'),
3127             (BranchChange, 'delete'),
3128             (AnnotatedTagChange, 'delete'),
3129             (NonAnnotatedTagChange, 'delete'),
3130             (OtherReferenceChange, 'update'),
3131             (OtherReferenceChange, 'create'),
3132             (OtherReferenceChange, 'delete'),
3133             ])
3134         )
3135
3136     def __init__(self, environment, changes, ignore_other_refs=False):
3137         self.changes = sorted(changes, key=self._sort_key)
3138         self.__other_ref_sha1s = None
3139         self.__cached_commits_spec = {}
3140         self.environment = environment
3141
3142         if ignore_other_refs:
3143             self.__other_ref_sha1s = set()
3144
3145     @classmethod
3146     def _sort_key(klass, change):
3147         return (klass.SORT_ORDER[change.__class__, change.change_type], change.refname,)
3148
3149     @property
3150     def _other_ref_sha1s(self):
3151         """The GitObjects referred to by references unaffected by this push.
3152         """
3153         if self.__other_ref_sha1s is None:
3154             # The refnames being changed by this push:
3155             updated_refs = set(
3156                 change.refname
3157                 for change in self.changes
3158                 )
3159
3160             # The SHA-1s of commits referred to by all references in this
3161             # repository *except* updated_refs:
3162             sha1s = set()
3163             fmt = (
3164                 '%(objectname) %(objecttype) %(refname)\n'
3165                 '%(*objectname) %(*objecttype) %(refname)'
3166                 )
3167             ref_filter_regex, is_inclusion_filter = \
3168                 self.environment.get_ref_filter_regex()
3169             for line in read_git_lines(
3170                     ['for-each-ref', '--format=%s' % (fmt,)]):
3171                 (sha1, type, name) = line.split(' ', 2)
3172                 if (sha1 and type == 'commit' and
3173                         name not in updated_refs and
3174                         include_ref(name, ref_filter_regex, is_inclusion_filter)):
3175                     sha1s.add(sha1)
3176
3177             self.__other_ref_sha1s = sha1s
3178
3179         return self.__other_ref_sha1s
3180
3181     def _get_commits_spec_incl(self, new_or_old, reference_change=None):
3182         """Get new or old SHA-1 from one or each of the changed refs.
3183
3184         Return a list of SHA-1 commit identifier strings suitable as
3185         arguments to 'git rev-list' (or 'git log' or ...).  The
3186         returned identifiers are either the old or new values from one
3187         or all of the changed references, depending on the values of
3188         new_or_old and reference_change.
3189
3190         new_or_old is either the string 'new' or the string 'old'.  If
3191         'new', the returned SHA-1 identifiers are the new values from
3192         each changed reference.  If 'old', the SHA-1 identifiers are
3193         the old values from each changed reference.
3194
3195         If reference_change is specified and not None, only the new or
3196         old reference from the specified reference is included in the
3197         return value.
3198
3199         This function returns None if there are no matching revisions
3200         (e.g., because a branch was deleted and new_or_old is 'new').
3201         """
3202
3203         if not reference_change:
3204             incl_spec = sorted(
3205                 getattr(change, new_or_old).sha1
3206                 for change in self.changes
3207                 if getattr(change, new_or_old)
3208                 )
3209             if not incl_spec:
3210                 incl_spec = None
3211         elif not getattr(reference_change, new_or_old).commit_sha1:
3212             incl_spec = None
3213         else:
3214             incl_spec = [getattr(reference_change, new_or_old).commit_sha1]
3215         return incl_spec
3216
3217     def _get_commits_spec_excl(self, new_or_old):
3218         """Get exclusion revisions for determining new or discarded commits.
3219
3220         Return a list of strings suitable as arguments to 'git
3221         rev-list' (or 'git log' or ...) that will exclude all
3222         commits that, depending on the value of new_or_old, were
3223         either previously in the repository (useful for determining
3224         which commits are new to the repository) or currently in the
3225         repository (useful for determining which commits were
3226         discarded from the repository).
3227
3228         new_or_old is either the string 'new' or the string 'old'.  If
3229         'new', the commits to be excluded are those that were in the
3230         repository before the push.  If 'old', the commits to be
3231         excluded are those that are currently in the repository.  """
3232
3233         old_or_new = {'old': 'new', 'new': 'old'}[new_or_old]
3234         excl_revs = self._other_ref_sha1s.union(
3235             getattr(change, old_or_new).sha1
3236             for change in self.changes
3237             if getattr(change, old_or_new).type in ['commit', 'tag']
3238             )
3239         return ['^' + sha1 for sha1 in sorted(excl_revs)]
3240
3241     def get_commits_spec(self, new_or_old, reference_change=None):
3242         """Get rev-list arguments for added or discarded commits.
3243
3244         Return a list of strings suitable as arguments to 'git
3245         rev-list' (or 'git log' or ...) that select those commits
3246         that, depending on the value of new_or_old, are either new to
3247         the repository or were discarded from the repository.
3248
3249         new_or_old is either the string 'new' or the string 'old'.  If
3250         'new', the returned list is used to select commits that are
3251         new to the repository.  If 'old', the returned value is used
3252         to select the commits that have been discarded from the
3253         repository.
3254
3255         If reference_change is specified and not None, the new or
3256         discarded commits are limited to those that are reachable from
3257         the new or old value of the specified reference.
3258
3259         This function returns None if there are no added (or discarded)
3260         revisions.
3261         """
3262         key = (new_or_old, reference_change)
3263         if key not in self.__cached_commits_spec:
3264             ret = self._get_commits_spec_incl(new_or_old, reference_change)
3265             if ret is not None:
3266                 ret.extend(self._get_commits_spec_excl(new_or_old))
3267             self.__cached_commits_spec[key] = ret
3268         return self.__cached_commits_spec[key]
3269
3270     def get_new_commits(self, reference_change=None):
3271         """Return a list of commits added by this push.
3272
3273         Return a list of the object names of commits that were added
3274         by the part of this push represented by reference_change.  If
3275         reference_change is None, then return a list of *all* commits
3276         added by this push."""
3277
3278         spec = self.get_commits_spec('new', reference_change)
3279         return git_rev_list(spec)
3280
3281     def get_discarded_commits(self, reference_change):
3282         """Return a list of commits discarded by this push.
3283
3284         Return a list of the object names of commits that were
3285         entirely discarded from the repository by the part of this
3286         push represented by reference_change."""
3287
3288         spec = self.get_commits_spec('old', reference_change)
3289         return git_rev_list(spec)
3290
3291     def send_emails(self, mailer, body_filter=None):
3292         """Use send all of the notification emails needed for this push.
3293
3294         Use send all of the notification emails (including reference
3295         change emails and commit emails) needed for this push.  Send
3296         the emails using mailer.  If body_filter is not None, then use
3297         it to filter the lines that are intended for the email
3298         body."""
3299
3300         # The sha1s of commits that were introduced by this push.
3301         # They will be removed from this set as they are processed, to
3302         # guarantee that one (and only one) email is generated for
3303         # each new commit.
3304         unhandled_sha1s = set(self.get_new_commits())
3305         send_date = IncrementalDateTime()
3306         for change in self.changes:
3307             sha1s = []
3308             for sha1 in reversed(list(self.get_new_commits(change))):
3309                 if sha1 in unhandled_sha1s:
3310                     sha1s.append(sha1)
3311                     unhandled_sha1s.remove(sha1)
3312
3313             # Check if we've got anyone to send to
3314             if not change.recipients:
3315                 change.environment.log_warning(
3316                     '*** no recipients configured so no email will be sent\n'
3317                     '*** for %r update %s->%s\n'
3318                     % (change.refname, change.old.sha1, change.new.sha1,)
3319                     )
3320             else:
3321                 if not change.environment.quiet:
3322                     change.environment.log_msg(
3323                         'Sending notification emails to: %s\n' % (change.recipients,))
3324                 extra_values = {'send_date': next(send_date)}
3325
3326                 rev = change.send_single_combined_email(sha1s)
3327                 if rev:
3328                     mailer.send(
3329                         change.generate_combined_email(self, rev, body_filter, extra_values),
3330                         rev.recipients,
3331                         )
3332                     # This change is now fully handled; no need to handle
3333                     # individual revisions any further.
3334                     continue
3335                 else:
3336                     mailer.send(
3337                         change.generate_email(self, body_filter, extra_values),
3338                         change.recipients,
3339                         )
3340
3341             max_emails = change.environment.maxcommitemails
3342             if max_emails and len(sha1s) > max_emails:
3343                 change.environment.log_warning(
3344                     '*** Too many new commits (%d), not sending commit emails.\n' % len(sha1s) +
3345                     '*** Try setting multimailhook.maxCommitEmails to a greater value\n' +
3346                     '*** Currently, multimailhook.maxCommitEmails=%d\n' % max_emails
3347                     )
3348                 return
3349
3350             for (num, sha1) in enumerate(sha1s):
3351                 rev = Revision(change, GitObject(sha1), num=num + 1, tot=len(sha1s))
3352                 if not rev.recipients and rev.cc_recipients:
3353                     change.environment.log_msg('*** Replacing Cc: with To:\n')
3354                     rev.recipients = rev.cc_recipients
3355                     rev.cc_recipients = None
3356                 if rev.recipients:
3357                     extra_values = {'send_date': next(send_date)}
3358                     mailer.send(
3359                         rev.generate_email(self, body_filter, extra_values),
3360                         rev.recipients,
3361                         )
3362
3363         # Consistency check:
3364         if unhandled_sha1s:
3365             change.environment.log_error(
3366                 'ERROR: No emails were sent for the following new commits:\n'
3367                 '    %s\n'
3368                 % ('\n    '.join(sorted(unhandled_sha1s)),)
3369                 )
3370
3371
3372 def include_ref(refname, ref_filter_regex, is_inclusion_filter):
3373     does_match = bool(ref_filter_regex.search(refname))
3374     if is_inclusion_filter:
3375         return does_match
3376     else:  # exclusion filter -- we include the ref if the regex doesn't match
3377         return not does_match
3378
3379
3380 def run_as_post_receive_hook(environment, mailer):
3381     ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(True)
3382     changes = []
3383     for line in sys.stdin:
3384         (oldrev, newrev, refname) = line.strip().split(' ', 2)
3385         if not include_ref(refname, ref_filter_regex, is_inclusion_filter):
3386             continue
3387         changes.append(
3388             ReferenceChange.create(environment, oldrev, newrev, refname)
3389             )
3390     if changes:
3391         push = Push(environment, changes)
3392         push.send_emails(mailer, body_filter=environment.filter_body)
3393
3394
3395 def run_as_update_hook(environment, mailer, refname, oldrev, newrev, force_send=False):
3396     ref_filter_regex, is_inclusion_filter = environment.get_ref_filter_regex(True)
3397     if not include_ref(refname, ref_filter_regex, is_inclusion_filter):
3398         return
3399     changes = [
3400         ReferenceChange.create(
3401             environment,
3402             read_git_output(['rev-parse', '--verify', oldrev]),
3403             read_git_output(['rev-parse', '--verify', newrev]),
3404             refname,
3405             ),
3406         ]
3407     push = Push(environment, changes, force_send)
3408     push.send_emails(mailer, body_filter=environment.filter_body)
3409
3410
3411 def choose_mailer(config, environment):
3412     mailer = config.get('mailer', default='sendmail')
3413
3414     if mailer == 'smtp':
3415         smtpserver = config.get('smtpserver', default='localhost')
3416         smtpservertimeout = float(config.get('smtpservertimeout', default=10.0))
3417         smtpserverdebuglevel = int(config.get('smtpserverdebuglevel', default=0))
3418         smtpencryption = config.get('smtpencryption', default='none')
3419         smtpuser = config.get('smtpuser', default='')
3420         smtppass = config.get('smtppass', default='')
3421         mailer = SMTPMailer(
3422             envelopesender=(environment.get_sender() or environment.get_fromaddr()),
3423             smtpserver=smtpserver, smtpservertimeout=smtpservertimeout,
3424             smtpserverdebuglevel=smtpserverdebuglevel,
3425             smtpencryption=smtpencryption,
3426             smtpuser=smtpuser,
3427             smtppass=smtppass,
3428             )
3429     elif mailer == 'sendmail':
3430         command = config.get('sendmailcommand')
3431         if command:
3432             command = shlex.split(command)
3433         mailer = SendMailer(command=command, envelopesender=environment.get_sender())
3434     else:
3435         environment.log_error(
3436             'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n' % mailer +
3437             'please use one of "smtp" or "sendmail".\n'
3438             )
3439         sys.exit(1)
3440     return mailer
3441
3442
3443 KNOWN_ENVIRONMENTS = {
3444     'generic': GenericEnvironmentMixin,
3445     'gitolite': GitoliteEnvironmentMixin,
3446     'stash': StashEnvironmentMixin,
3447     'gerrit': GerritEnvironmentMixin,
3448     }
3449
3450
3451 def choose_environment(config, osenv=None, env=None, recipients=None,
3452                        hook_info=None):
3453     if not osenv:
3454         osenv = os.environ
3455
3456     environment_mixins = [
3457         ConfigRefFilterEnvironmentMixin,
3458         ProjectdescEnvironmentMixin,
3459         ConfigMaxlinesEnvironmentMixin,
3460         ComputeFQDNEnvironmentMixin,
3461         ConfigFilterLinesEnvironmentMixin,
3462         PusherDomainEnvironmentMixin,
3463         ConfigOptionsEnvironmentMixin,
3464         ]
3465     environment_kw = {
3466         'osenv': osenv,
3467         'config': config,
3468         }
3469
3470     if not env:
3471         env = config.get('environment')
3472
3473     if not env:
3474         if 'GL_USER' in osenv and 'GL_REPO' in osenv:
3475             env = 'gitolite'
3476         else:
3477             env = 'generic'
3478
3479     environment_mixins.insert(0, KNOWN_ENVIRONMENTS[env])
3480
3481     if env == 'stash':
3482         environment_kw['user'] = hook_info['stash_user']
3483         environment_kw['repo'] = hook_info['stash_repo']
3484     elif env == 'gerrit':
3485         environment_kw['project'] = hook_info['project']
3486         environment_kw['submitter'] = hook_info['submitter']
3487         environment_kw['update_method'] = hook_info['update_method']
3488
3489     if recipients:
3490         environment_mixins.insert(0, StaticRecipientsEnvironmentMixin)
3491         environment_kw['refchange_recipients'] = recipients
3492         environment_kw['announce_recipients'] = recipients
3493         environment_kw['revision_recipients'] = recipients
3494         environment_kw['scancommitforcc'] = config.get('scancommitforcc')
3495     else:
3496         environment_mixins.insert(0, ConfigRecipientsEnvironmentMixin)
3497
3498     environment_klass = type(
3499         'EffectiveEnvironment',
3500         tuple(environment_mixins) + (Environment,),
3501         {},
3502         )
3503     return environment_klass(**environment_kw)
3504
3505
3506 def get_version():
3507     oldcwd = os.getcwd()
3508     try:
3509         try:
3510             os.chdir(os.path.dirname(os.path.realpath(__file__)))
3511             git_version = read_git_output(['describe', '--tags', 'HEAD'])
3512             if git_version == __version__:
3513                 return git_version
3514             else:
3515                 return '%s (%s)' % (__version__, git_version)
3516         except:
3517             pass
3518     finally:
3519         os.chdir(oldcwd)
3520     return __version__
3521
3522
3523 def compute_gerrit_options(options, args, required_gerrit_options):
3524     if None in required_gerrit_options:
3525         raise SystemExit("Error: Specify all of --oldrev, --newrev, --refname, "
3526                          "and --project; or none of them.")
3527
3528     if options.environment not in (None, 'gerrit'):
3529         raise SystemExit("Non-gerrit environments incompatible with --oldrev, "
3530                          "--newrev, --refname, and --project")
3531     options.environment = 'gerrit'
3532
3533     if args:
3534         raise SystemExit("Error: Positional parameters not allowed with "
3535                          "--oldrev, --newrev, and --refname.")
3536
3537     # Gerrit oddly omits 'refs/heads/' in the refname when calling
3538     # ref-updated hook; put it back.
3539     git_dir = get_git_dir()
3540     if (not os.path.exists(os.path.join(git_dir, options.refname)) and
3541         os.path.exists(os.path.join(git_dir, 'refs', 'heads',
3542                                     options.refname))):
3543         options.refname = 'refs/heads/' + options.refname
3544
3545     # Convert each string option unicode for Python3.
3546     if PYTHON3:
3547         opts = ['environment', 'recipients', 'oldrev', 'newrev', 'refname',
3548                 'project', 'submitter', 'stash-user', 'stash-repo']
3549         for opt in opts:
3550             if not hasattr(options, opt):
3551                 continue
3552             obj = getattr(options, opt)
3553             if obj:
3554                 enc = obj.encode('utf-8', 'surrogateescape')
3555                 dec = enc.decode('utf-8', 'replace')
3556                 setattr(options, opt, dec)
3557
3558     # New revisions can appear in a gerrit repository either due to someone
3559     # pushing directly (in which case options.submitter will be set), or they
3560     # can press "Submit this patchset" in the web UI for some CR (in which
3561     # case options.submitter will not be set and gerrit will not have provided
3562     # us the information about who pressed the button).
3563     #
3564     # Note for the nit-picky: I'm lumping in REST API calls and the ssh
3565     # gerrit review command in with "Submit this patchset" button, since they
3566     # have the same effect.
3567     if options.submitter:
3568         update_method = 'pushed'
3569         # The submitter argument is almost an RFC 2822 email address; change it
3570         # from 'User Name (email@domain)' to 'User Name <email@domain>' so it is
3571         options.submitter = options.submitter.replace('(', '<').replace(')', '>')
3572     else:
3573         update_method = 'submitted'
3574         # Gerrit knew who submitted this patchset, but threw that information
3575         # away when it invoked this hook.  However, *IF* Gerrit created a
3576         # merge to bring the patchset in (project 'Submit Type' is either
3577         # "Always Merge", or is "Merge if Necessary" and happens to be
3578         # necessary for this particular CR), then it will have the committer
3579         # of that merge be 'Gerrit Code Review' and the author will be the
3580         # person who requested the submission of the CR.  Since this is fairly
3581         # likely for most gerrit installations (of a reasonable size), it's
3582         # worth the extra effort to try to determine the actual submitter.
3583         rev_info = read_git_lines(['log', '--no-walk', '--merges',
3584                                    '--format=%cN%n%aN <%aE>', options.newrev])
3585         if rev_info and rev_info[0] == 'Gerrit Code Review':
3586             options.submitter = rev_info[1]
3587
3588     # We pass back refname, oldrev, newrev as args because then the
3589     # gerrit ref-updated hook is much like the git update hook
3590     return (options,
3591             [options.refname, options.oldrev, options.newrev],
3592             {'project': options.project, 'submitter': options.submitter,
3593              'update_method': update_method})
3594
3595
3596 def check_hook_specific_args(options, args):
3597     # First check for stash arguments
3598     if (options.stash_user is None) != (options.stash_repo is None):
3599         raise SystemExit("Error: Specify both of --stash-user and "
3600                          "--stash-repo or neither.")
3601     if options.stash_user:
3602         options.environment = 'stash'
3603         return options, args, {'stash_user': options.stash_user,
3604                                'stash_repo': options.stash_repo}
3605
3606     # Finally, check for gerrit specific arguments
3607     required_gerrit_options = (options.oldrev, options.newrev, options.refname,
3608                                options.project)
3609     if required_gerrit_options != (None,) * 4:
3610         return compute_gerrit_options(options, args, required_gerrit_options)
3611
3612     # No special options in use, just return what we started with
3613     return options, args, {}
3614
3615
3616 def main(args):
3617     parser = optparse.OptionParser(
3618         description=__doc__,
3619         usage='%prog [OPTIONS]\n   or: %prog [OPTIONS] REFNAME OLDREV NEWREV',
3620         )
3621
3622     parser.add_option(
3623         '--environment', '--env', action='store', type='choice',
3624         choices=list(KNOWN_ENVIRONMENTS.keys()), default=None,
3625         help=(
3626             'Choose type of environment is in use.  Default is taken from '
3627             'multimailhook.environment if set; otherwise "generic".'
3628             ),
3629         )
3630     parser.add_option(
3631         '--stdout', action='store_true', default=False,
3632         help='Output emails to stdout rather than sending them.',
3633         )
3634     parser.add_option(
3635         '--recipients', action='store', default=None,
3636         help='Set list of email recipients for all types of emails.',
3637         )
3638     parser.add_option(
3639         '--show-env', action='store_true', default=False,
3640         help=(
3641             'Write to stderr the values determined for the environment '
3642             '(intended for debugging purposes).'
3643             ),
3644         )
3645     parser.add_option(
3646         '--force-send', action='store_true', default=False,
3647         help=(
3648             'Force sending refchange email when using as an update hook. '
3649             'This is useful to work around the unreliable new commits '
3650             'detection in this mode.'
3651             ),
3652         )
3653     parser.add_option(
3654         '-c', metavar="<name>=<value>", action='append',
3655         help=(
3656             'Pass a configuration parameter through to git.  The value given '
3657             'will override values from configuration files.  See the -c option '
3658             'of git(1) for more details.  (Only works with git >= 1.7.3)'
3659             ),
3660         )
3661     parser.add_option(
3662         '--version', '-v', action='store_true', default=False,
3663         help=(
3664             "Display git-multimail's version"
3665             ),
3666         )
3667     # The following options permit this script to be run as a gerrit
3668     # ref-updated hook.  See e.g.
3669     # code.google.com/p/gerrit/source/browse/Documentation/config-hooks.txt
3670     # We suppress help for these items, since these are specific to gerrit,
3671     # and we don't want users directly using them any way other than how the
3672     # gerrit ref-updated hook is called.
3673     parser.add_option('--oldrev', action='store', help=optparse.SUPPRESS_HELP)
3674     parser.add_option('--newrev', action='store', help=optparse.SUPPRESS_HELP)
3675     parser.add_option('--refname', action='store', help=optparse.SUPPRESS_HELP)
3676     parser.add_option('--project', action='store', help=optparse.SUPPRESS_HELP)
3677     parser.add_option('--submitter', action='store', help=optparse.SUPPRESS_HELP)
3678
3679     # The following allow this to be run as a stash asynchronous post-receive
3680     # hook (almost identical to a git post-receive hook but triggered also for
3681     # merges of pull requests from the UI).  We suppress help for these items,
3682     # since these are specific to stash.
3683     parser.add_option('--stash-user', action='store', help=optparse.SUPPRESS_HELP)
3684     parser.add_option('--stash-repo', action='store', help=optparse.SUPPRESS_HELP)
3685
3686     (options, args) = parser.parse_args(args)
3687     (options, args, hook_info) = check_hook_specific_args(options, args)
3688
3689     if options.version:
3690         sys.stdout.write('git-multimail version ' + get_version() + '\n')
3691         return
3692
3693     if options.c:
3694         parameters = os.environ.get('GIT_CONFIG_PARAMETERS', '')
3695         if parameters:
3696             parameters += ' '
3697         # git expects GIT_CONFIG_PARAMETERS to be of the form
3698         #    "'name1=value1' 'name2=value2' 'name3=value3'"
3699         # including everything inside the double quotes (but not the double
3700         # quotes themselves).  Spacing is critical.  Also, if a value contains
3701         # a literal single quote that quote must be represented using the
3702         # four character sequence: '\''
3703         parameters += ' '.join("'" + x.replace("'", "'\\''") + "'" for x in options.c)
3704         os.environ['GIT_CONFIG_PARAMETERS'] = parameters
3705
3706     config = Config('multimailhook')
3707
3708     try:
3709         environment = choose_environment(
3710             config, osenv=os.environ,
3711             env=options.environment,
3712             recipients=options.recipients,
3713             hook_info=hook_info,
3714             )
3715
3716         if options.show_env:
3717             sys.stderr.write('Environment values:\n')
3718             for (k, v) in sorted(environment.get_values().items()):
3719                 sys.stderr.write('    %s : %r\n' % (k, v))
3720             sys.stderr.write('\n')
3721
3722         if options.stdout or environment.stdout:
3723             mailer = OutputMailer(sys.stdout)
3724         else:
3725             mailer = choose_mailer(config, environment)
3726
3727         # Dual mode: if arguments were specified on the command line, run
3728         # like an update hook; otherwise, run as a post-receive hook.
3729         if args:
3730             if len(args) != 3:
3731                 parser.error('Need zero or three non-option arguments')
3732             (refname, oldrev, newrev) = args
3733             run_as_update_hook(environment, mailer, refname, oldrev, newrev, options.force_send)
3734         else:
3735             run_as_post_receive_hook(environment, mailer)
3736     except ConfigurationException:
3737         sys.exit(sys.exc_info()[1])
3738     except Exception:
3739         t, e, tb = sys.exc_info()
3740         import traceback
3741         sys.stdout.write('\n')
3742         sys.stdout.write('Exception \'' + t.__name__ +
3743                          '\' raised. Please report this as a bug to\n')
3744         sys.stdout.write('https://github.com/git-multimail/git-multimail/issues\n')
3745         sys.stdout.write('with the information below:\n\n')
3746         sys.stdout.write('git-multimail version ' + get_version() + '\n')
3747         sys.stdout.write('Python version ' + sys.version + '\n')
3748         traceback.print_exc(file=sys.stdout)
3749         sys.exit(1)
3750
3751 if __name__ == '__main__':
3752     main(sys.argv[1:])