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