Merge branch 'jk/commit-C-pick-empty' into maint
[git] / contrib / hooks / multimail / git_multimail.py
1 #! /usr/bin/env python2
2
3 # Copyright (c) 2012-2014 Michael Haggerty and others
4 # Derived from contrib/hooks/post-receive-email, which is
5 # Copyright (c) 2007 Andy Parkins
6 # and also includes contributions by other authors.
7 #
8 # This file is part of git-multimail.
9 #
10 # git-multimail is free software: you can redistribute it and/or
11 # modify it under the terms of the GNU General Public License version
12 # 2 as published by the Free Software Foundation.
13 #
14 # This program is distributed in the hope that it will be useful, but
15 # WITHOUT ANY WARRANTY; without even the implied warranty of
16 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
17 # General Public License for more details.
18 #
19 # You should have received a copy of the GNU General Public License
20 # along with this program.  If not, see
21 # <http://www.gnu.org/licenses/>.
22
23 """Generate notification emails for pushes to a git repository.
24
25 This hook sends emails describing changes introduced by pushes to a
26 git repository.  For each reference that was changed, it emits one
27 ReferenceChange email summarizing how the reference was changed,
28 followed by one Revision email for each new commit that was introduced
29 by the reference change.
30
31 Each commit is announced in exactly one Revision email.  If the same
32 commit is merged into another branch in the same or a later push, then
33 the ReferenceChange email will list the commit's SHA1 and its one-line
34 summary, but no new Revision email will be generated.
35
36 This script is designed to be used as a "post-receive" hook in a git
37 repository (see githooks(5)).  It can also be used as an "update"
38 script, but this usage is not completely reliable and is deprecated.
39
40 To help with debugging, this script accepts a --stdout option, which
41 causes the emails to be written to standard output rather than sent
42 using sendmail.
43
44 See the accompanying README file for the complete documentation.
45
46 """
47
48 import sys
49 import os
50 import re
51 import bisect
52 import socket
53 import subprocess
54 import shlex
55 import optparse
56 import smtplib
57 import time
58
59 try:
60     from email.utils import make_msgid
61     from email.utils import getaddresses
62     from email.utils import formataddr
63     from email.utils import formatdate
64     from email.header import Header
65 except ImportError:
66     # Prior to Python 2.5, the email module used different names:
67     from email.Utils import make_msgid
68     from email.Utils import getaddresses
69     from email.Utils import formataddr
70     from email.Utils import formatdate
71     from email.Header import Header
72
73
74 DEBUG = False
75
76 ZEROS = '0' * 40
77 LOGBEGIN = '- Log -----------------------------------------------------------------\n'
78 LOGEND = '-----------------------------------------------------------------------\n'
79
80 ADDR_HEADERS = set(['from', 'to', 'cc', 'bcc', 'reply-to', 'sender'])
81
82 # It is assumed in many places that the encoding is uniformly UTF-8,
83 # so changing these constants is unsupported.  But define them here
84 # anyway, to make it easier to find (at least most of) the places
85 # where the encoding is important.
86 (ENCODING, CHARSET) = ('UTF-8', 'utf-8')
87
88
89 REF_CREATED_SUBJECT_TEMPLATE = (
90     '%(emailprefix)s%(refname_type)s %(short_refname)s created'
91     ' (now %(newrev_short)s)'
92     )
93 REF_UPDATED_SUBJECT_TEMPLATE = (
94     '%(emailprefix)s%(refname_type)s %(short_refname)s updated'
95     ' (%(oldrev_short)s -> %(newrev_short)s)'
96     )
97 REF_DELETED_SUBJECT_TEMPLATE = (
98     '%(emailprefix)s%(refname_type)s %(short_refname)s deleted'
99     ' (was %(oldrev_short)s)'
100     )
101
102 REFCHANGE_HEADER_TEMPLATE = """\
103 Date: %(send_date)s
104 To: %(recipients)s
105 Subject: %(subject)s
106 MIME-Version: 1.0
107 Content-Type: text/plain; charset=%(charset)s
108 Content-Transfer-Encoding: 8bit
109 Message-ID: %(msgid)s
110 From: %(fromaddr)s
111 Reply-To: %(reply_to)s
112 X-Git-Host: %(fqdn)s
113 X-Git-Repo: %(repo_shortname)s
114 X-Git-Refname: %(refname)s
115 X-Git-Reftype: %(refname_type)s
116 X-Git-Oldrev: %(oldrev)s
117 X-Git-Newrev: %(newrev)s
118 Auto-Submitted: auto-generated
119 """
120
121 REFCHANGE_INTRO_TEMPLATE = """\
122 This is an automated email from the git hooks/post-receive script.
123
124 %(pusher)s pushed a change to %(refname_type)s %(short_refname)s
125 in repository %(repo_shortname)s.
126
127 """
128
129
130 FOOTER_TEMPLATE = """\
131
132 -- \n\
133 To stop receiving notification emails like this one, please contact
134 %(administrator)s.
135 """
136
137
138 REWIND_ONLY_TEMPLATE = """\
139 This update removed existing revisions from the reference, leaving the
140 reference pointing at a previous point in the repository history.
141
142  * -- * -- N   %(refname)s (%(newrev_short)s)
143             \\
144              O -- O -- O   (%(oldrev_short)s)
145
146 Any revisions marked "omits" are not gone; other references still
147 refer to them.  Any revisions marked "discards" are gone forever.
148 """
149
150
151 NON_FF_TEMPLATE = """\
152 This update added new revisions after undoing existing revisions.
153 That is to say, some revisions that were in the old version of the
154 %(refname_type)s are not in the new version.  This situation occurs
155 when a user --force pushes a change and generates a repository
156 containing something like this:
157
158  * -- * -- B -- O -- O -- O   (%(oldrev_short)s)
159             \\
160              N -- N -- N   %(refname)s (%(newrev_short)s)
161
162 You should already have received notification emails for all of the O
163 revisions, and so the following emails describe only the N revisions
164 from the common base, B.
165
166 Any revisions marked "omits" are not gone; other references still
167 refer to them.  Any revisions marked "discards" are gone forever.
168 """
169
170
171 NO_NEW_REVISIONS_TEMPLATE = """\
172 No new revisions were added by this update.
173 """
174
175
176 DISCARDED_REVISIONS_TEMPLATE = """\
177 This change permanently discards the following revisions:
178 """
179
180
181 NO_DISCARDED_REVISIONS_TEMPLATE = """\
182 The revisions that were on this %(refname_type)s are still contained in
183 other references; therefore, this change does not discard any commits
184 from the repository.
185 """
186
187
188 NEW_REVISIONS_TEMPLATE = """\
189 The %(tot)s revisions listed above as "new" are entirely new to this
190 repository and will be described in separate emails.  The revisions
191 listed as "adds" were already present in the repository and have only
192 been added to this reference.
193
194 """
195
196
197 TAG_CREATED_TEMPLATE = """\
198         at  %(newrev_short)-9s (%(newrev_type)s)
199 """
200
201
202 TAG_UPDATED_TEMPLATE = """\
203 *** WARNING: tag %(short_refname)s was modified! ***
204
205       from  %(oldrev_short)-9s (%(oldrev_type)s)
206         to  %(newrev_short)-9s (%(newrev_type)s)
207 """
208
209
210 TAG_DELETED_TEMPLATE = """\
211 *** WARNING: tag %(short_refname)s was deleted! ***
212
213 """
214
215
216 # The template used in summary tables.  It looks best if this uses the
217 # same alignment as TAG_CREATED_TEMPLATE and TAG_UPDATED_TEMPLATE.
218 BRIEF_SUMMARY_TEMPLATE = """\
219 %(action)10s  %(rev_short)-9s %(text)s
220 """
221
222
223 NON_COMMIT_UPDATE_TEMPLATE = """\
224 This is an unusual reference change because the reference did not
225 refer to a commit either before or after the change.  We do not know
226 how to provide full information about this reference change.
227 """
228
229
230 REVISION_HEADER_TEMPLATE = """\
231 Date: %(send_date)s
232 To: %(recipients)s
233 Subject: %(emailprefix)s%(num)02d/%(tot)02d: %(oneline)s
234 MIME-Version: 1.0
235 Content-Type: text/plain; charset=%(charset)s
236 Content-Transfer-Encoding: 8bit
237 From: %(fromaddr)s
238 Reply-To: %(reply_to)s
239 In-Reply-To: %(reply_to_msgid)s
240 References: %(reply_to_msgid)s
241 X-Git-Host: %(fqdn)s
242 X-Git-Repo: %(repo_shortname)s
243 X-Git-Refname: %(refname)s
244 X-Git-Reftype: %(refname_type)s
245 X-Git-Rev: %(rev)s
246 Auto-Submitted: auto-generated
247 """
248
249 REVISION_INTRO_TEMPLATE = """\
250 This is an automated email from the git hooks/post-receive script.
251
252 %(pusher)s pushed a commit to %(refname_type)s %(short_refname)s
253 in repository %(repo_shortname)s.
254
255 """
256
257
258 REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE
259
260
261 class CommandError(Exception):
262     def __init__(self, cmd, retcode):
263         self.cmd = cmd
264         self.retcode = retcode
265         Exception.__init__(
266             self,
267             'Command "%s" failed with retcode %s' % (' '.join(cmd), retcode,)
268             )
269
270
271 class ConfigurationException(Exception):
272     pass
273
274
275 # The "git" program (this could be changed to include a full path):
276 GIT_EXECUTABLE = 'git'
277
278
279 # How "git" should be invoked (including global arguments), as a list
280 # of words.  This variable is usually initialized automatically by
281 # read_git_output() via choose_git_command(), but if a value is set
282 # here then it will be used unconditionally.
283 GIT_CMD = None
284
285
286 def choose_git_command():
287     """Decide how to invoke git, and record the choice in GIT_CMD."""
288
289     global GIT_CMD
290
291     if GIT_CMD is None:
292         try:
293             # Check to see whether the "-c" option is accepted (it was
294             # only added in Git 1.7.2).  We don't actually use the
295             # output of "git --version", though if we needed more
296             # specific version information this would be the place to
297             # do it.
298             cmd = [GIT_EXECUTABLE, '-c', 'foo.bar=baz', '--version']
299             read_output(cmd)
300             GIT_CMD = [GIT_EXECUTABLE, '-c', 'i18n.logoutputencoding=%s' % (ENCODING,)]
301         except CommandError:
302             GIT_CMD = [GIT_EXECUTABLE]
303
304
305 def read_git_output(args, input=None, keepends=False, **kw):
306     """Read the output of a Git command."""
307
308     if GIT_CMD is None:
309         choose_git_command()
310
311     return read_output(GIT_CMD + args, input=input, keepends=keepends, **kw)
312
313
314 def read_output(cmd, input=None, keepends=False, **kw):
315     if input:
316         stdin = subprocess.PIPE
317     else:
318         stdin = None
319     p = subprocess.Popen(
320         cmd, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kw
321         )
322     (out, err) = p.communicate(input)
323     retcode = p.wait()
324     if retcode:
325         raise CommandError(cmd, retcode)
326     if not keepends:
327         out = out.rstrip('\n\r')
328     return out
329
330
331 def read_git_lines(args, keepends=False, **kw):
332     """Return the lines output by Git command.
333
334     Return as single lines, with newlines stripped off."""
335
336     return read_git_output(args, keepends=True, **kw).splitlines(keepends)
337
338
339 def header_encode(text, header_name=None):
340     """Encode and line-wrap the value of an email header field."""
341
342     try:
343         if isinstance(text, str):
344             text = text.decode(ENCODING, 'replace')
345         return Header(text, header_name=header_name).encode()
346     except UnicodeEncodeError:
347         return Header(text, header_name=header_name, charset=CHARSET,
348                       errors='replace').encode()
349
350
351 def addr_header_encode(text, header_name=None):
352     """Encode and line-wrap the value of an email header field containing
353     email addresses."""
354
355     return Header(
356         ', '.join(
357             formataddr((header_encode(name), emailaddr))
358             for name, emailaddr in getaddresses([text])
359             ),
360         header_name=header_name
361         ).encode()
362
363
364 class Config(object):
365     def __init__(self, section, git_config=None):
366         """Represent a section of the git configuration.
367
368         If git_config is specified, it is passed to "git config" in
369         the GIT_CONFIG environment variable, meaning that "git config"
370         will read the specified path rather than the Git default
371         config paths."""
372
373         self.section = section
374         if git_config:
375             self.env = os.environ.copy()
376             self.env['GIT_CONFIG'] = git_config
377         else:
378             self.env = None
379
380     @staticmethod
381     def _split(s):
382         """Split NUL-terminated values."""
383
384         words = s.split('\0')
385         assert words[-1] == ''
386         return words[:-1]
387
388     def get(self, name, default=None):
389         try:
390             values = self._split(read_git_output(
391                     ['config', '--get', '--null', '%s.%s' % (self.section, name)],
392                     env=self.env, keepends=True,
393                     ))
394             assert len(values) == 1
395             return values[0]
396         except CommandError:
397             return default
398
399     def get_bool(self, name, default=None):
400         try:
401             value = read_git_output(
402                 ['config', '--get', '--bool', '%s.%s' % (self.section, name)],
403                 env=self.env,
404                 )
405         except CommandError:
406             return default
407         return value == 'true'
408
409     def get_all(self, name, default=None):
410         """Read a (possibly multivalued) setting from the configuration.
411
412         Return the result as a list of values, or default if the name
413         is unset."""
414
415         try:
416             return self._split(read_git_output(
417                 ['config', '--get-all', '--null', '%s.%s' % (self.section, name)],
418                 env=self.env, keepends=True,
419                 ))
420         except CommandError, e:
421             if e.retcode == 1:
422                 # "the section or key is invalid"; i.e., there is no
423                 # value for the specified key.
424                 return default
425             else:
426                 raise
427
428     def get_recipients(self, name, default=None):
429         """Read a recipients list from the configuration.
430
431         Return the result as a comma-separated list of email
432         addresses, or default if the option is unset.  If the setting
433         has multiple values, concatenate them with comma separators."""
434
435         lines = self.get_all(name, default=None)
436         if lines is None:
437             return default
438         return ', '.join(line.strip() for line in lines)
439
440     def set(self, name, value):
441         read_git_output(
442             ['config', '%s.%s' % (self.section, name), value],
443             env=self.env,
444             )
445
446     def add(self, name, value):
447         read_git_output(
448             ['config', '--add', '%s.%s' % (self.section, name), value],
449             env=self.env,
450             )
451
452     def has_key(self, name):
453         return self.get_all(name, default=None) is not None
454
455     def unset_all(self, name):
456         try:
457             read_git_output(
458                 ['config', '--unset-all', '%s.%s' % (self.section, name)],
459                 env=self.env,
460                 )
461         except CommandError, e:
462             if e.retcode == 5:
463                 # The name doesn't exist, which is what we wanted anyway...
464                 pass
465             else:
466                 raise
467
468     def set_recipients(self, name, value):
469         self.unset_all(name)
470         for pair in getaddresses([value]):
471             self.add(name, formataddr(pair))
472
473
474 def generate_summaries(*log_args):
475     """Generate a brief summary for each revision requested.
476
477     log_args are strings that will be passed directly to "git log" as
478     revision selectors.  Iterate over (sha1_short, subject) for each
479     commit specified by log_args (subject is the first line of the
480     commit message as a string without EOLs)."""
481
482     cmd = [
483         'log', '--abbrev', '--format=%h %s',
484         ] + list(log_args) + ['--']
485     for line in read_git_lines(cmd):
486         yield tuple(line.split(' ', 1))
487
488
489 def limit_lines(lines, max_lines):
490     for (index, line) in enumerate(lines):
491         if index < max_lines:
492             yield line
493
494     if index >= max_lines:
495         yield '... %d lines suppressed ...\n' % (index + 1 - max_lines,)
496
497
498 def limit_linelength(lines, max_linelength):
499     for line in lines:
500         # Don't forget that lines always include a trailing newline.
501         if len(line) > max_linelength + 1:
502             line = line[:max_linelength - 7] + ' [...]\n'
503         yield line
504
505
506 class CommitSet(object):
507     """A (constant) set of object names.
508
509     The set should be initialized with full SHA1 object names.  The
510     __contains__() method returns True iff its argument is an
511     abbreviation of any the names in the set."""
512
513     def __init__(self, names):
514         self._names = sorted(names)
515
516     def __len__(self):
517         return len(self._names)
518
519     def __contains__(self, sha1_abbrev):
520         """Return True iff this set contains sha1_abbrev (which might be abbreviated)."""
521
522         i = bisect.bisect_left(self._names, sha1_abbrev)
523         return i < len(self) and self._names[i].startswith(sha1_abbrev)
524
525
526 class GitObject(object):
527     def __init__(self, sha1, type=None):
528         if sha1 == ZEROS:
529             self.sha1 = self.type = self.commit_sha1 = None
530         else:
531             self.sha1 = sha1
532             self.type = type or read_git_output(['cat-file', '-t', self.sha1])
533
534             if self.type == 'commit':
535                 self.commit_sha1 = self.sha1
536             elif self.type == 'tag':
537                 try:
538                     self.commit_sha1 = read_git_output(
539                         ['rev-parse', '--verify', '%s^0' % (self.sha1,)]
540                         )
541                 except CommandError:
542                     # Cannot deref tag to determine commit_sha1
543                     self.commit_sha1 = None
544             else:
545                 self.commit_sha1 = None
546
547         self.short = read_git_output(['rev-parse', '--short', sha1])
548
549     def get_summary(self):
550         """Return (sha1_short, subject) for this commit."""
551
552         if not self.sha1:
553             raise ValueError('Empty commit has no summary')
554
555         return iter(generate_summaries('--no-walk', self.sha1)).next()
556
557     def __eq__(self, other):
558         return isinstance(other, GitObject) and self.sha1 == other.sha1
559
560     def __hash__(self):
561         return hash(self.sha1)
562
563     def __nonzero__(self):
564         return bool(self.sha1)
565
566     def __str__(self):
567         return self.sha1 or ZEROS
568
569
570 class Change(object):
571     """A Change that has been made to the Git repository.
572
573     Abstract class from which both Revisions and ReferenceChanges are
574     derived.  A Change knows how to generate a notification email
575     describing itself."""
576
577     def __init__(self, environment):
578         self.environment = environment
579         self._values = None
580
581     def _compute_values(self):
582         """Return a dictionary {keyword : expansion} for this Change.
583
584         Derived classes overload this method to add more entries to
585         the return value.  This method is used internally by
586         get_values().  The return value should always be a new
587         dictionary."""
588
589         return self.environment.get_values()
590
591     def get_values(self, **extra_values):
592         """Return a dictionary {keyword : expansion} for this Change.
593
594         Return a dictionary mapping keywords to the values that they
595         should be expanded to for this Change (used when interpolating
596         template strings).  If any keyword arguments are supplied, add
597         those to the return value as well.  The return value is always
598         a new dictionary."""
599
600         if self._values is None:
601             self._values = self._compute_values()
602
603         values = self._values.copy()
604         if extra_values:
605             values.update(extra_values)
606         return values
607
608     def expand(self, template, **extra_values):
609         """Expand template.
610
611         Expand the template (which should be a string) using string
612         interpolation of the values for this Change.  If any keyword
613         arguments are provided, also include those in the keywords
614         available for interpolation."""
615
616         return template % self.get_values(**extra_values)
617
618     def expand_lines(self, template, **extra_values):
619         """Break template into lines and expand each line."""
620
621         values = self.get_values(**extra_values)
622         for line in template.splitlines(True):
623             yield line % values
624
625     def expand_header_lines(self, template, **extra_values):
626         """Break template into lines and expand each line as an RFC 2822 header.
627
628         Encode values and split up lines that are too long.  Silently
629         skip lines that contain references to unknown variables."""
630
631         values = self.get_values(**extra_values)
632         for line in template.splitlines():
633             (name, value) = line.split(':', 1)
634
635             try:
636                 value = value % values
637             except KeyError, e:
638                 if DEBUG:
639                     sys.stderr.write(
640                         'Warning: unknown variable %r in the following line; line skipped:\n'
641                         '    %s\n'
642                         % (e.args[0], line,)
643                         )
644             else:
645                 if name.lower() in ADDR_HEADERS:
646                     value = addr_header_encode(value, name)
647                 else:
648                     value = header_encode(value, name)
649                 for splitline in ('%s: %s\n' % (name, value)).splitlines(True):
650                     yield splitline
651
652     def generate_email_header(self):
653         """Generate the RFC 2822 email headers for this Change, a line at a time.
654
655         The output should not include the trailing blank line."""
656
657         raise NotImplementedError()
658
659     def generate_email_intro(self):
660         """Generate the email intro for this Change, a line at a time.
661
662         The output will be used as the standard boilerplate at the top
663         of the email body."""
664
665         raise NotImplementedError()
666
667     def generate_email_body(self):
668         """Generate the main part of the email body, a line at a time.
669
670         The text in the body might be truncated after a specified
671         number of lines (see multimailhook.emailmaxlines)."""
672
673         raise NotImplementedError()
674
675     def generate_email_footer(self):
676         """Generate the footer of the email, a line at a time.
677
678         The footer is always included, irrespective of
679         multimailhook.emailmaxlines."""
680
681         raise NotImplementedError()
682
683     def generate_email(self, push, body_filter=None, extra_header_values={}):
684         """Generate an email describing this change.
685
686         Iterate over the lines (including the header lines) of an
687         email describing this change.  If body_filter is not None,
688         then use it to filter the lines that are intended for the
689         email body.
690
691         The extra_header_values field is received as a dict and not as
692         **kwargs, to allow passing other keyword arguments in the
693         future (e.g. passing extra values to generate_email_intro()"""
694
695         for line in self.generate_email_header(**extra_header_values):
696             yield line
697         yield '\n'
698         for line in self.generate_email_intro():
699             yield line
700
701         body = self.generate_email_body(push)
702         if body_filter is not None:
703             body = body_filter(body)
704         for line in body:
705             yield line
706
707         for line in self.generate_email_footer():
708             yield line
709
710
711 class Revision(Change):
712     """A Change consisting of a single git commit."""
713
714     def __init__(self, reference_change, rev, num, tot):
715         Change.__init__(self, reference_change.environment)
716         self.reference_change = reference_change
717         self.rev = rev
718         self.change_type = self.reference_change.change_type
719         self.refname = self.reference_change.refname
720         self.num = num
721         self.tot = tot
722         self.author = read_git_output(['log', '--no-walk', '--format=%aN <%aE>', self.rev.sha1])
723         self.recipients = self.environment.get_revision_recipients(self)
724
725     def _compute_values(self):
726         values = Change._compute_values(self)
727
728         oneline = read_git_output(
729             ['log', '--format=%s', '--no-walk', self.rev.sha1]
730             )
731
732         values['rev'] = self.rev.sha1
733         values['rev_short'] = self.rev.short
734         values['change_type'] = self.change_type
735         values['refname'] = self.refname
736         values['short_refname'] = self.reference_change.short_refname
737         values['refname_type'] = self.reference_change.refname_type
738         values['reply_to_msgid'] = self.reference_change.msgid
739         values['num'] = self.num
740         values['tot'] = self.tot
741         values['recipients'] = self.recipients
742         values['oneline'] = oneline
743         values['author'] = self.author
744
745         reply_to = self.environment.get_reply_to_commit(self)
746         if reply_to:
747             values['reply_to'] = reply_to
748
749         return values
750
751     def generate_email_header(self, **extra_values):
752         for line in self.expand_header_lines(
753             REVISION_HEADER_TEMPLATE, **extra_values
754             ):
755             yield line
756
757     def generate_email_intro(self):
758         for line in self.expand_lines(REVISION_INTRO_TEMPLATE):
759             yield line
760
761     def generate_email_body(self, push):
762         """Show this revision."""
763
764         return read_git_lines(
765             ['log'] + self.environment.commitlogopts + ['-1', self.rev.sha1],
766             keepends=True,
767             )
768
769     def generate_email_footer(self):
770         return self.expand_lines(REVISION_FOOTER_TEMPLATE)
771
772
773 class ReferenceChange(Change):
774     """A Change to a Git reference.
775
776     An abstract class representing a create, update, or delete of a
777     Git reference.  Derived classes handle specific types of reference
778     (e.g., tags vs. branches).  These classes generate the main
779     reference change email summarizing the reference change and
780     whether it caused any any commits to be added or removed.
781
782     ReferenceChange objects are usually created using the static
783     create() method, which has the logic to decide which derived class
784     to instantiate."""
785
786     REF_RE = re.compile(r'^refs\/(?P<area>[^\/]+)\/(?P<shortname>.*)$')
787
788     @staticmethod
789     def create(environment, oldrev, newrev, refname):
790         """Return a ReferenceChange object representing the change.
791
792         Return an object that represents the type of change that is being
793         made. oldrev and newrev should be SHA1s or ZEROS."""
794
795         old = GitObject(oldrev)
796         new = GitObject(newrev)
797         rev = new or old
798
799         # The revision type tells us what type the commit is, combined with
800         # the location of the ref we can decide between
801         #  - working branch
802         #  - tracking branch
803         #  - unannotated tag
804         #  - annotated tag
805         m = ReferenceChange.REF_RE.match(refname)
806         if m:
807             area = m.group('area')
808             short_refname = m.group('shortname')
809         else:
810             area = ''
811             short_refname = refname
812
813         if rev.type == 'tag':
814             # Annotated tag:
815             klass = AnnotatedTagChange
816         elif rev.type == 'commit':
817             if area == 'tags':
818                 # Non-annotated tag:
819                 klass = NonAnnotatedTagChange
820             elif area == 'heads':
821                 # Branch:
822                 klass = BranchChange
823             elif area == 'remotes':
824                 # Tracking branch:
825                 sys.stderr.write(
826                     '*** Push-update of tracking branch %r\n'
827                     '***  - incomplete email generated.\n'
828                      % (refname,)
829                     )
830                 klass = OtherReferenceChange
831             else:
832                 # Some other reference namespace:
833                 sys.stderr.write(
834                     '*** Push-update of strange reference %r\n'
835                     '***  - incomplete email generated.\n'
836                      % (refname,)
837                     )
838                 klass = OtherReferenceChange
839         else:
840             # Anything else (is there anything else?)
841             sys.stderr.write(
842                 '*** Unknown type of update to %r (%s)\n'
843                 '***  - incomplete email generated.\n'
844                  % (refname, rev.type,)
845                 )
846             klass = OtherReferenceChange
847
848         return klass(
849             environment,
850             refname=refname, short_refname=short_refname,
851             old=old, new=new, rev=rev,
852             )
853
854     def __init__(self, environment, refname, short_refname, old, new, rev):
855         Change.__init__(self, environment)
856         self.change_type = {
857             (False, True) : 'create',
858             (True, True) : 'update',
859             (True, False) : 'delete',
860             }[bool(old), bool(new)]
861         self.refname = refname
862         self.short_refname = short_refname
863         self.old = old
864         self.new = new
865         self.rev = rev
866         self.msgid = make_msgid()
867         self.diffopts = environment.diffopts
868         self.logopts = environment.logopts
869         self.commitlogopts = environment.commitlogopts
870         self.showlog = environment.refchange_showlog
871
872     def _compute_values(self):
873         values = Change._compute_values(self)
874
875         values['change_type'] = self.change_type
876         values['refname_type'] = self.refname_type
877         values['refname'] = self.refname
878         values['short_refname'] = self.short_refname
879         values['msgid'] = self.msgid
880         values['recipients'] = self.recipients
881         values['oldrev'] = str(self.old)
882         values['oldrev_short'] = self.old.short
883         values['newrev'] = str(self.new)
884         values['newrev_short'] = self.new.short
885
886         if self.old:
887             values['oldrev_type'] = self.old.type
888         if self.new:
889             values['newrev_type'] = self.new.type
890
891         reply_to = self.environment.get_reply_to_refchange(self)
892         if reply_to:
893             values['reply_to'] = reply_to
894
895         return values
896
897     def get_subject(self):
898         template = {
899             'create' : REF_CREATED_SUBJECT_TEMPLATE,
900             'update' : REF_UPDATED_SUBJECT_TEMPLATE,
901             'delete' : REF_DELETED_SUBJECT_TEMPLATE,
902             }[self.change_type]
903         return self.expand(template)
904
905     def generate_email_header(self, **extra_values):
906         if 'subject' not in extra_values:
907             extra_values['subject'] = self.get_subject()
908
909         for line in self.expand_header_lines(
910             REFCHANGE_HEADER_TEMPLATE, **extra_values
911             ):
912             yield line
913
914     def generate_email_intro(self):
915         for line in self.expand_lines(REFCHANGE_INTRO_TEMPLATE):
916             yield line
917
918     def generate_email_body(self, push):
919         """Call the appropriate body-generation routine.
920
921         Call one of generate_create_summary() /
922         generate_update_summary() / generate_delete_summary()."""
923
924         change_summary = {
925             'create' : self.generate_create_summary,
926             'delete' : self.generate_delete_summary,
927             'update' : self.generate_update_summary,
928             }[self.change_type](push)
929         for line in change_summary:
930             yield line
931
932         for line in self.generate_revision_change_summary(push):
933             yield line
934
935     def generate_email_footer(self):
936         return self.expand_lines(FOOTER_TEMPLATE)
937
938     def generate_revision_change_log(self, new_commits_list):
939         if self.showlog:
940             yield '\n'
941             yield 'Detailed log of new commits:\n\n'
942             for line in read_git_lines(
943                     ['log', '--no-walk']
944                     + self.logopts
945                     + new_commits_list
946                     + ['--'],
947                     keepends=True,
948                 ):
949                 yield line
950
951     def generate_revision_change_summary(self, push):
952         """Generate a summary of the revisions added/removed by this change."""
953
954         if self.new.commit_sha1 and not self.old.commit_sha1:
955             # A new reference was created.  List the new revisions
956             # brought by the new reference (i.e., those revisions that
957             # were not in the repository before this reference
958             # change).
959             sha1s = list(push.get_new_commits(self))
960             sha1s.reverse()
961             tot = len(sha1s)
962             new_revisions = [
963                 Revision(self, GitObject(sha1), num=i+1, tot=tot)
964                 for (i, sha1) in enumerate(sha1s)
965                 ]
966
967             if new_revisions:
968                 yield self.expand('This %(refname_type)s includes the following new commits:\n')
969                 yield '\n'
970                 for r in new_revisions:
971                     (sha1, subject) = r.rev.get_summary()
972                     yield r.expand(
973                         BRIEF_SUMMARY_TEMPLATE, action='new', text=subject,
974                         )
975                 yield '\n'
976                 for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot):
977                     yield line
978                 for line in self.generate_revision_change_log([r.rev.sha1 for r in new_revisions]):
979                     yield line
980             else:
981                 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
982                     yield line
983
984         elif self.new.commit_sha1 and self.old.commit_sha1:
985             # A reference was changed to point at a different commit.
986             # List the revisions that were removed and/or added *from
987             # that reference* by this reference change, along with a
988             # diff between the trees for its old and new values.
989
990             # List of the revisions that were added to the branch by
991             # this update.  Note this list can include revisions that
992             # have already had notification emails; we want such
993             # revisions in the summary even though we will not send
994             # new notification emails for them.
995             adds = list(generate_summaries(
996                     '--topo-order', '--reverse', '%s..%s'
997                     % (self.old.commit_sha1, self.new.commit_sha1,)
998                     ))
999
1000             # List of the revisions that were removed from the branch
1001             # by this update.  This will be empty except for
1002             # non-fast-forward updates.
1003             discards = list(generate_summaries(
1004                     '%s..%s' % (self.new.commit_sha1, self.old.commit_sha1,)
1005                     ))
1006
1007             if adds:
1008                 new_commits_list = push.get_new_commits(self)
1009             else:
1010                 new_commits_list = []
1011             new_commits = CommitSet(new_commits_list)
1012
1013             if discards:
1014                 discarded_commits = CommitSet(push.get_discarded_commits(self))
1015             else:
1016                 discarded_commits = CommitSet([])
1017
1018             if discards and adds:
1019                 for (sha1, subject) in discards:
1020                     if sha1 in discarded_commits:
1021                         action = 'discards'
1022                     else:
1023                         action = 'omits'
1024                     yield self.expand(
1025                         BRIEF_SUMMARY_TEMPLATE, action=action,
1026                         rev_short=sha1, text=subject,
1027                         )
1028                 for (sha1, subject) in adds:
1029                     if sha1 in new_commits:
1030                         action = 'new'
1031                     else:
1032                         action = 'adds'
1033                     yield self.expand(
1034                         BRIEF_SUMMARY_TEMPLATE, action=action,
1035                         rev_short=sha1, text=subject,
1036                         )
1037                 yield '\n'
1038                 for line in self.expand_lines(NON_FF_TEMPLATE):
1039                     yield line
1040
1041             elif discards:
1042                 for (sha1, subject) in discards:
1043                     if sha1 in discarded_commits:
1044                         action = 'discards'
1045                     else:
1046                         action = 'omits'
1047                     yield self.expand(
1048                         BRIEF_SUMMARY_TEMPLATE, action=action,
1049                         rev_short=sha1, text=subject,
1050                         )
1051                 yield '\n'
1052                 for line in self.expand_lines(REWIND_ONLY_TEMPLATE):
1053                     yield line
1054
1055             elif adds:
1056                 (sha1, subject) = self.old.get_summary()
1057                 yield self.expand(
1058                     BRIEF_SUMMARY_TEMPLATE, action='from',
1059                     rev_short=sha1, text=subject,
1060                     )
1061                 for (sha1, subject) in adds:
1062                     if sha1 in new_commits:
1063                         action = 'new'
1064                     else:
1065                         action = 'adds'
1066                     yield self.expand(
1067                         BRIEF_SUMMARY_TEMPLATE, action=action,
1068                         rev_short=sha1, text=subject,
1069                         )
1070
1071             yield '\n'
1072
1073             if new_commits:
1074                 for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=len(new_commits)):
1075                     yield line
1076                 for line in self.generate_revision_change_log(new_commits_list):
1077                     yield line
1078             else:
1079                 for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
1080                     yield line
1081
1082             # The diffstat is shown from the old revision to the new
1083             # revision.  This is to show the truth of what happened in
1084             # this change.  There's no point showing the stat from the
1085             # base to the new revision because the base is effectively a
1086             # random revision at this point - the user will be interested
1087             # in what this revision changed - including the undoing of
1088             # previous revisions in the case of non-fast-forward updates.
1089             yield '\n'
1090             yield 'Summary of changes:\n'
1091             for line in read_git_lines(
1092                 ['diff-tree']
1093                 + self.diffopts
1094                 + ['%s..%s' % (self.old.commit_sha1, self.new.commit_sha1,)],
1095                 keepends=True,
1096                 ):
1097                 yield line
1098
1099         elif self.old.commit_sha1 and not self.new.commit_sha1:
1100             # A reference was deleted.  List the revisions that were
1101             # removed from the repository by this reference change.
1102
1103             sha1s = list(push.get_discarded_commits(self))
1104             tot = len(sha1s)
1105             discarded_revisions = [
1106                 Revision(self, GitObject(sha1), num=i+1, tot=tot)
1107                 for (i, sha1) in enumerate(sha1s)
1108                 ]
1109
1110             if discarded_revisions:
1111                 for line in self.expand_lines(DISCARDED_REVISIONS_TEMPLATE):
1112                     yield line
1113                 yield '\n'
1114                 for r in discarded_revisions:
1115                     (sha1, subject) = r.rev.get_summary()
1116                     yield r.expand(
1117                         BRIEF_SUMMARY_TEMPLATE, action='discards', text=subject,
1118                         )
1119             else:
1120                 for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE):
1121                     yield line
1122
1123         elif not self.old.commit_sha1 and not self.new.commit_sha1:
1124             for line in self.expand_lines(NON_COMMIT_UPDATE_TEMPLATE):
1125                 yield line
1126
1127     def generate_create_summary(self, push):
1128         """Called for the creation of a reference."""
1129
1130         # This is a new reference and so oldrev is not valid
1131         (sha1, subject) = self.new.get_summary()
1132         yield self.expand(
1133             BRIEF_SUMMARY_TEMPLATE, action='at',
1134             rev_short=sha1, text=subject,
1135             )
1136         yield '\n'
1137
1138     def generate_update_summary(self, push):
1139         """Called for the change of a pre-existing branch."""
1140
1141         return iter([])
1142
1143     def generate_delete_summary(self, push):
1144         """Called for the deletion of any type of reference."""
1145
1146         (sha1, subject) = self.old.get_summary()
1147         yield self.expand(
1148             BRIEF_SUMMARY_TEMPLATE, action='was',
1149             rev_short=sha1, text=subject,
1150             )
1151         yield '\n'
1152
1153
1154 class BranchChange(ReferenceChange):
1155     refname_type = 'branch'
1156
1157     def __init__(self, environment, refname, short_refname, old, new, rev):
1158         ReferenceChange.__init__(
1159             self, environment,
1160             refname=refname, short_refname=short_refname,
1161             old=old, new=new, rev=rev,
1162             )
1163         self.recipients = environment.get_refchange_recipients(self)
1164
1165
1166 class AnnotatedTagChange(ReferenceChange):
1167     refname_type = 'annotated tag'
1168
1169     def __init__(self, environment, refname, short_refname, old, new, rev):
1170         ReferenceChange.__init__(
1171             self, environment,
1172             refname=refname, short_refname=short_refname,
1173             old=old, new=new, rev=rev,
1174             )
1175         self.recipients = environment.get_announce_recipients(self)
1176         self.show_shortlog = environment.announce_show_shortlog
1177
1178     ANNOTATED_TAG_FORMAT = (
1179         '%(*objectname)\n'
1180         '%(*objecttype)\n'
1181         '%(taggername)\n'
1182         '%(taggerdate)'
1183         )
1184
1185     def describe_tag(self, push):
1186         """Describe the new value of an annotated tag."""
1187
1188         # Use git for-each-ref to pull out the individual fields from
1189         # the tag
1190         [tagobject, tagtype, tagger, tagged] = read_git_lines(
1191             ['for-each-ref', '--format=%s' % (self.ANNOTATED_TAG_FORMAT,), self.refname],
1192             )
1193
1194         yield self.expand(
1195             BRIEF_SUMMARY_TEMPLATE, action='tagging',
1196             rev_short=tagobject, text='(%s)' % (tagtype,),
1197             )
1198         if tagtype == 'commit':
1199             # If the tagged object is a commit, then we assume this is a
1200             # release, and so we calculate which tag this tag is
1201             # replacing
1202             try:
1203                 prevtag = read_git_output(['describe', '--abbrev=0', '%s^' % (self.new,)])
1204             except CommandError:
1205                 prevtag = None
1206             if prevtag:
1207                 yield '  replaces  %s\n' % (prevtag,)
1208         else:
1209             prevtag = None
1210             yield '    length  %s bytes\n' % (read_git_output(['cat-file', '-s', tagobject]),)
1211
1212         yield ' tagged by  %s\n' % (tagger,)
1213         yield '        on  %s\n' % (tagged,)
1214         yield '\n'
1215
1216         # Show the content of the tag message; this might contain a
1217         # change log or release notes so is worth displaying.
1218         yield LOGBEGIN
1219         contents = list(read_git_lines(['cat-file', 'tag', self.new.sha1], keepends=True))
1220         contents = contents[contents.index('\n') + 1:]
1221         if contents and contents[-1][-1:] != '\n':
1222             contents.append('\n')
1223         for line in contents:
1224             yield line
1225
1226         if self.show_shortlog and tagtype == 'commit':
1227             # Only commit tags make sense to have rev-list operations
1228             # performed on them
1229             yield '\n'
1230             if prevtag:
1231                 # Show changes since the previous release
1232                 revlist = read_git_output(
1233                     ['rev-list', '--pretty=short', '%s..%s' % (prevtag, self.new,)],
1234                     keepends=True,
1235                     )
1236             else:
1237                 # No previous tag, show all the changes since time
1238                 # began
1239                 revlist = read_git_output(
1240                     ['rev-list', '--pretty=short', '%s' % (self.new,)],
1241                     keepends=True,
1242                     )
1243             for line in read_git_lines(['shortlog'], input=revlist, keepends=True):
1244                 yield line
1245
1246         yield LOGEND
1247         yield '\n'
1248
1249     def generate_create_summary(self, push):
1250         """Called for the creation of an annotated tag."""
1251
1252         for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1253             yield line
1254
1255         for line in self.describe_tag(push):
1256             yield line
1257
1258     def generate_update_summary(self, push):
1259         """Called for the update of an annotated tag.
1260
1261         This is probably a rare event and may not even be allowed."""
1262
1263         for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1264             yield line
1265
1266         for line in self.describe_tag(push):
1267             yield line
1268
1269     def generate_delete_summary(self, push):
1270         """Called when a non-annotated reference is updated."""
1271
1272         for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1273             yield line
1274
1275         yield self.expand('   tag was  %(oldrev_short)s\n')
1276         yield '\n'
1277
1278
1279 class NonAnnotatedTagChange(ReferenceChange):
1280     refname_type = 'tag'
1281
1282     def __init__(self, environment, refname, short_refname, old, new, rev):
1283         ReferenceChange.__init__(
1284             self, environment,
1285             refname=refname, short_refname=short_refname,
1286             old=old, new=new, rev=rev,
1287             )
1288         self.recipients = environment.get_refchange_recipients(self)
1289
1290     def generate_create_summary(self, push):
1291         """Called for the creation of an annotated tag."""
1292
1293         for line in self.expand_lines(TAG_CREATED_TEMPLATE):
1294             yield line
1295
1296     def generate_update_summary(self, push):
1297         """Called when a non-annotated reference is updated."""
1298
1299         for line in self.expand_lines(TAG_UPDATED_TEMPLATE):
1300             yield line
1301
1302     def generate_delete_summary(self, push):
1303         """Called when a non-annotated reference is updated."""
1304
1305         for line in self.expand_lines(TAG_DELETED_TEMPLATE):
1306             yield line
1307
1308         for line in ReferenceChange.generate_delete_summary(self, push):
1309             yield line
1310
1311
1312 class OtherReferenceChange(ReferenceChange):
1313     refname_type = 'reference'
1314
1315     def __init__(self, environment, refname, short_refname, old, new, rev):
1316         # We use the full refname as short_refname, because otherwise
1317         # the full name of the reference would not be obvious from the
1318         # text of the email.
1319         ReferenceChange.__init__(
1320             self, environment,
1321             refname=refname, short_refname=refname,
1322             old=old, new=new, rev=rev,
1323             )
1324         self.recipients = environment.get_refchange_recipients(self)
1325
1326
1327 class Mailer(object):
1328     """An object that can send emails."""
1329
1330     def send(self, lines, to_addrs):
1331         """Send an email consisting of lines.
1332
1333         lines must be an iterable over the lines constituting the
1334         header and body of the email.  to_addrs is a list of recipient
1335         addresses (can be needed even if lines already contains a
1336         "To:" field).  It can be either a string (comma-separated list
1337         of email addresses) or a Python list of individual email
1338         addresses.
1339
1340         """
1341
1342         raise NotImplementedError()
1343
1344
1345 class SendMailer(Mailer):
1346     """Send emails using 'sendmail -oi -t'."""
1347
1348     SENDMAIL_CANDIDATES = [
1349         '/usr/sbin/sendmail',
1350         '/usr/lib/sendmail',
1351         ]
1352
1353     @staticmethod
1354     def find_sendmail():
1355         for path in SendMailer.SENDMAIL_CANDIDATES:
1356             if os.access(path, os.X_OK):
1357                 return path
1358         else:
1359             raise ConfigurationException(
1360                 'No sendmail executable found.  '
1361                 'Try setting multimailhook.sendmailCommand.'
1362                 )
1363
1364     def __init__(self, command=None, envelopesender=None):
1365         """Construct a SendMailer instance.
1366
1367         command should be the command and arguments used to invoke
1368         sendmail, as a list of strings.  If an envelopesender is
1369         provided, it will also be passed to the command, via '-f
1370         envelopesender'."""
1371
1372         if command:
1373             self.command = command[:]
1374         else:
1375             self.command = [self.find_sendmail(), '-oi', '-t']
1376
1377         if envelopesender:
1378             self.command.extend(['-f', envelopesender])
1379
1380     def send(self, lines, to_addrs):
1381         try:
1382             p = subprocess.Popen(self.command, stdin=subprocess.PIPE)
1383         except OSError, e:
1384             sys.stderr.write(
1385                 '*** Cannot execute command: %s\n' % ' '.join(self.command)
1386                 + '*** %s\n' % str(e)
1387                 + '*** Try setting multimailhook.mailer to "smtp"\n'
1388                 '*** to send emails without using the sendmail command.\n'
1389                 )
1390             sys.exit(1)
1391         try:
1392             p.stdin.writelines(lines)
1393         except:
1394             sys.stderr.write(
1395                 '*** Error while generating commit email\n'
1396                 '***  - mail sending aborted.\n'
1397                 )
1398             p.terminate()
1399             raise
1400         else:
1401             p.stdin.close()
1402             retcode = p.wait()
1403             if retcode:
1404                 raise CommandError(self.command, retcode)
1405
1406
1407 class SMTPMailer(Mailer):
1408     """Send emails using Python's smtplib."""
1409
1410     def __init__(self, envelopesender, smtpserver):
1411         if not envelopesender:
1412             sys.stderr.write(
1413                 'fatal: git_multimail: cannot use SMTPMailer without a sender address.\n'
1414                 'please set either multimailhook.envelopeSender or user.email\n'
1415                 )
1416             sys.exit(1)
1417         self.envelopesender = envelopesender
1418         self.smtpserver = smtpserver
1419         try:
1420             self.smtp = smtplib.SMTP(self.smtpserver)
1421         except Exception, e:
1422             sys.stderr.write('*** Error establishing SMTP connection to %s***\n' % self.smtpserver)
1423             sys.stderr.write('*** %s\n' % str(e))
1424             sys.exit(1)
1425
1426     def __del__(self):
1427         self.smtp.quit()
1428
1429     def send(self, lines, to_addrs):
1430         try:
1431             msg = ''.join(lines)
1432             # turn comma-separated list into Python list if needed.
1433             if isinstance(to_addrs, basestring):
1434                 to_addrs = [email for (name, email) in getaddresses([to_addrs])]
1435             self.smtp.sendmail(self.envelopesender, to_addrs, msg)
1436         except Exception, e:
1437             sys.stderr.write('*** Error sending email***\n')
1438             sys.stderr.write('*** %s\n' % str(e))
1439             self.smtp.quit()
1440             sys.exit(1)
1441
1442
1443 class OutputMailer(Mailer):
1444     """Write emails to an output stream, bracketed by lines of '=' characters.
1445
1446     This is intended for debugging purposes."""
1447
1448     SEPARATOR = '=' * 75 + '\n'
1449
1450     def __init__(self, f):
1451         self.f = f
1452
1453     def send(self, lines, to_addrs):
1454         self.f.write(self.SEPARATOR)
1455         self.f.writelines(lines)
1456         self.f.write(self.SEPARATOR)
1457
1458
1459 def get_git_dir():
1460     """Determine GIT_DIR.
1461
1462     Determine GIT_DIR either from the GIT_DIR environment variable or
1463     from the working directory, using Git's usual rules."""
1464
1465     try:
1466         return read_git_output(['rev-parse', '--git-dir'])
1467     except CommandError:
1468         sys.stderr.write('fatal: git_multimail: not in a git directory\n')
1469         sys.exit(1)
1470
1471
1472 class Environment(object):
1473     """Describes the environment in which the push is occurring.
1474
1475     An Environment object encapsulates information about the local
1476     environment.  For example, it knows how to determine:
1477
1478     * the name of the repository to which the push occurred
1479
1480     * what user did the push
1481
1482     * what users want to be informed about various types of changes.
1483
1484     An Environment object is expected to have the following methods:
1485
1486         get_repo_shortname()
1487
1488             Return a short name for the repository, for display
1489             purposes.
1490
1491         get_repo_path()
1492
1493             Return the absolute path to the Git repository.
1494
1495         get_emailprefix()
1496
1497             Return a string that will be prefixed to every email's
1498             subject.
1499
1500         get_pusher()
1501
1502             Return the username of the person who pushed the changes.
1503             This value is used in the email body to indicate who
1504             pushed the change.
1505
1506         get_pusher_email() (may return None)
1507
1508             Return the email address of the person who pushed the
1509             changes.  The value should be a single RFC 2822 email
1510             address as a string; e.g., "Joe User <user@example.com>"
1511             if available, otherwise "user@example.com".  If set, the
1512             value is used as the Reply-To address for refchange
1513             emails.  If it is impossible to determine the pusher's
1514             email, this attribute should be set to None (in which case
1515             no Reply-To header will be output).
1516
1517         get_sender()
1518
1519             Return the address to be used as the 'From' email address
1520             in the email envelope.
1521
1522         get_fromaddr()
1523
1524             Return the 'From' email address used in the email 'From:'
1525             headers.  (May be a full RFC 2822 email address like 'Joe
1526             User <user@example.com>'.)
1527
1528         get_administrator()
1529
1530             Return the name and/or email of the repository
1531             administrator.  This value is used in the footer as the
1532             person to whom requests to be removed from the
1533             notification list should be sent.  Ideally, it should
1534             include a valid email address.
1535
1536         get_reply_to_refchange()
1537         get_reply_to_commit()
1538
1539             Return the address to use in the email "Reply-To" header,
1540             as a string.  These can be an RFC 2822 email address, or
1541             None to omit the "Reply-To" header.
1542             get_reply_to_refchange() is used for refchange emails;
1543             get_reply_to_commit() is used for individual commit
1544             emails.
1545
1546     They should also define the following attributes:
1547
1548         announce_show_shortlog (bool)
1549
1550             True iff announce emails should include a shortlog.
1551
1552         refchange_showlog (bool)
1553
1554             True iff refchanges emails should include a detailed log.
1555
1556         diffopts (list of strings)
1557
1558             The options that should be passed to 'git diff' for the
1559             summary email.  The value should be a list of strings
1560             representing words to be passed to the command.
1561
1562         logopts (list of strings)
1563
1564             Analogous to diffopts, but contains options passed to
1565             'git log' when generating the detailed log for a set of
1566             commits (see refchange_showlog)
1567
1568         commitlogopts (list of strings)
1569
1570             The options that should be passed to 'git log' for each
1571             commit mail.  The value should be a list of strings
1572             representing words to be passed to the command.
1573
1574     """
1575
1576     REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$')
1577
1578     def __init__(self, osenv=None):
1579         self.osenv = osenv or os.environ
1580         self.announce_show_shortlog = False
1581         self.maxcommitemails = 500
1582         self.diffopts = ['--stat', '--summary', '--find-copies-harder']
1583         self.logopts = []
1584         self.refchange_showlog = False
1585         self.commitlogopts = ['-C', '--stat', '-p', '--cc']
1586
1587         self.COMPUTED_KEYS = [
1588             'administrator',
1589             'charset',
1590             'emailprefix',
1591             'fromaddr',
1592             'pusher',
1593             'pusher_email',
1594             'repo_path',
1595             'repo_shortname',
1596             'sender',
1597             ]
1598
1599         self._values = None
1600
1601     def get_repo_shortname(self):
1602         """Use the last part of the repo path, with ".git" stripped off if present."""
1603
1604         basename = os.path.basename(os.path.abspath(self.get_repo_path()))
1605         m = self.REPO_NAME_RE.match(basename)
1606         if m:
1607             return m.group('name')
1608         else:
1609             return basename
1610
1611     def get_pusher(self):
1612         raise NotImplementedError()
1613
1614     def get_pusher_email(self):
1615         return None
1616
1617     def get_administrator(self):
1618         return 'the administrator of this repository'
1619
1620     def get_emailprefix(self):
1621         return ''
1622
1623     def get_repo_path(self):
1624         if read_git_output(['rev-parse', '--is-bare-repository']) == 'true':
1625             path = get_git_dir()
1626         else:
1627             path = read_git_output(['rev-parse', '--show-toplevel'])
1628         return os.path.abspath(path)
1629
1630     def get_charset(self):
1631         return CHARSET
1632
1633     def get_values(self):
1634         """Return a dictionary {keyword : expansion} for this Environment.
1635
1636         This method is called by Change._compute_values().  The keys
1637         in the returned dictionary are available to be used in any of
1638         the templates.  The dictionary is created by calling
1639         self.get_NAME() for each of the attributes named in
1640         COMPUTED_KEYS and recording those that do not return None.
1641         The return value is always a new dictionary."""
1642
1643         if self._values is None:
1644             values = {}
1645
1646             for key in self.COMPUTED_KEYS:
1647                 value = getattr(self, 'get_%s' % (key,))()
1648                 if value is not None:
1649                     values[key] = value
1650
1651             self._values = values
1652
1653         return self._values.copy()
1654
1655     def get_refchange_recipients(self, refchange):
1656         """Return the recipients for notifications about refchange.
1657
1658         Return the list of email addresses to which notifications
1659         about the specified ReferenceChange should be sent."""
1660
1661         raise NotImplementedError()
1662
1663     def get_announce_recipients(self, annotated_tag_change):
1664         """Return the recipients for notifications about annotated_tag_change.
1665
1666         Return the list of email addresses to which notifications
1667         about the specified AnnotatedTagChange should be sent."""
1668
1669         raise NotImplementedError()
1670
1671     def get_reply_to_refchange(self, refchange):
1672         return self.get_pusher_email()
1673
1674     def get_revision_recipients(self, revision):
1675         """Return the recipients for messages about revision.
1676
1677         Return the list of email addresses to which notifications
1678         about the specified Revision should be sent.  This method
1679         could be overridden, for example, to take into account the
1680         contents of the revision when deciding whom to notify about
1681         it.  For example, there could be a scheme for users to express
1682         interest in particular files or subdirectories, and only
1683         receive notification emails for revisions that affecting those
1684         files."""
1685
1686         raise NotImplementedError()
1687
1688     def get_reply_to_commit(self, revision):
1689         return revision.author
1690
1691     def filter_body(self, lines):
1692         """Filter the lines intended for an email body.
1693
1694         lines is an iterable over the lines that would go into the
1695         email body.  Filter it (e.g., limit the number of lines, the
1696         line length, character set, etc.), returning another iterable.
1697         See FilterLinesEnvironmentMixin and MaxlinesEnvironmentMixin
1698         for classes implementing this functionality."""
1699
1700         return lines
1701
1702
1703 class ConfigEnvironmentMixin(Environment):
1704     """A mixin that sets self.config to its constructor's config argument.
1705
1706     This class's constructor consumes the "config" argument.
1707
1708     Mixins that need to inspect the config should inherit from this
1709     class (1) to make sure that "config" is still in the constructor
1710     arguments with its own constructor runs and/or (2) to be sure that
1711     self.config is set after construction."""
1712
1713     def __init__(self, config, **kw):
1714         super(ConfigEnvironmentMixin, self).__init__(**kw)
1715         self.config = config
1716
1717
1718 class ConfigOptionsEnvironmentMixin(ConfigEnvironmentMixin):
1719     """An Environment that reads most of its information from "git config"."""
1720
1721     def __init__(self, config, **kw):
1722         super(ConfigOptionsEnvironmentMixin, self).__init__(
1723             config=config, **kw
1724             )
1725
1726         self.announce_show_shortlog = config.get_bool(
1727             'announceshortlog', default=self.announce_show_shortlog
1728             )
1729
1730         self.refchange_showlog = config.get_bool(
1731             'refchangeshowlog', default=self.refchange_showlog
1732             )
1733
1734         maxcommitemails = config.get('maxcommitemails')
1735         if maxcommitemails is not None:
1736             try:
1737                 self.maxcommitemails = int(maxcommitemails)
1738             except ValueError:
1739                 sys.stderr.write(
1740                     '*** Malformed value for multimailhook.maxCommitEmails: %s\n' % maxcommitemails
1741                     + '*** Expected a number.  Ignoring.\n'
1742                     )
1743
1744         diffopts = config.get('diffopts')
1745         if diffopts is not None:
1746             self.diffopts = shlex.split(diffopts)
1747
1748         logopts = config.get('logopts')
1749         if logopts is not None:
1750             self.logopts = shlex.split(logopts)
1751
1752         commitlogopts = config.get('commitlogopts')
1753         if commitlogopts is not None:
1754             self.commitlogopts = shlex.split(commitlogopts)
1755
1756         reply_to = config.get('replyTo')
1757         self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to)
1758         if (
1759             self.__reply_to_refchange is not None
1760             and self.__reply_to_refchange.lower() == 'author'
1761             ):
1762             raise ConfigurationException(
1763                 '"author" is not an allowed setting for replyToRefchange'
1764                 )
1765         self.__reply_to_commit = config.get('replyToCommit', default=reply_to)
1766
1767     def get_administrator(self):
1768         return (
1769             self.config.get('administrator')
1770             or self.get_sender()
1771             or super(ConfigOptionsEnvironmentMixin, self).get_administrator()
1772             )
1773
1774     def get_repo_shortname(self):
1775         return (
1776             self.config.get('reponame')
1777             or super(ConfigOptionsEnvironmentMixin, self).get_repo_shortname()
1778             )
1779
1780     def get_emailprefix(self):
1781         emailprefix = self.config.get('emailprefix')
1782         if emailprefix and emailprefix.strip():
1783             return emailprefix.strip() + ' '
1784         else:
1785             return '[%s] ' % (self.get_repo_shortname(),)
1786
1787     def get_sender(self):
1788         return self.config.get('envelopesender')
1789
1790     def get_fromaddr(self):
1791         fromaddr = self.config.get('from')
1792         if fromaddr:
1793             return fromaddr
1794         else:
1795             config = Config('user')
1796             fromname = config.get('name', default='')
1797             fromemail = config.get('email', default='')
1798             if fromemail:
1799                 return formataddr([fromname, fromemail])
1800             else:
1801                 return self.get_sender()
1802
1803     def get_reply_to_refchange(self, refchange):
1804         if self.__reply_to_refchange is None:
1805             return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_refchange(refchange)
1806         elif self.__reply_to_refchange.lower() == 'pusher':
1807             return self.get_pusher_email()
1808         elif self.__reply_to_refchange.lower() == 'none':
1809             return None
1810         else:
1811             return self.__reply_to_refchange
1812
1813     def get_reply_to_commit(self, revision):
1814         if self.__reply_to_commit is None:
1815             return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_commit(revision)
1816         elif self.__reply_to_commit.lower() == 'author':
1817             return revision.get_author()
1818         elif self.__reply_to_commit.lower() == 'pusher':
1819             return self.get_pusher_email()
1820         elif self.__reply_to_commit.lower() == 'none':
1821             return None
1822         else:
1823             return self.__reply_to_commit
1824
1825
1826 class FilterLinesEnvironmentMixin(Environment):
1827     """Handle encoding and maximum line length of body lines.
1828
1829         emailmaxlinelength (int or None)
1830
1831             The maximum length of any single line in the email body.
1832             Longer lines are truncated at that length with ' [...]'
1833             appended.
1834
1835         strict_utf8 (bool)
1836
1837             If this field is set to True, then the email body text is
1838             expected to be UTF-8.  Any invalid characters are
1839             converted to U+FFFD, the Unicode replacement character
1840             (encoded as UTF-8, of course).
1841
1842     """
1843
1844     def __init__(self, strict_utf8=True, emailmaxlinelength=500, **kw):
1845         super(FilterLinesEnvironmentMixin, self).__init__(**kw)
1846         self.__strict_utf8 = strict_utf8
1847         self.__emailmaxlinelength = emailmaxlinelength
1848
1849     def filter_body(self, lines):
1850         lines = super(FilterLinesEnvironmentMixin, self).filter_body(lines)
1851         if self.__strict_utf8:
1852             lines = (line.decode(ENCODING, 'replace') for line in lines)
1853             # Limit the line length in Unicode-space to avoid
1854             # splitting characters:
1855             if self.__emailmaxlinelength:
1856                 lines = limit_linelength(lines, self.__emailmaxlinelength)
1857             lines = (line.encode(ENCODING, 'replace') for line in lines)
1858         elif self.__emailmaxlinelength:
1859             lines = limit_linelength(lines, self.__emailmaxlinelength)
1860
1861         return lines
1862
1863
1864 class ConfigFilterLinesEnvironmentMixin(
1865     ConfigEnvironmentMixin,
1866     FilterLinesEnvironmentMixin,
1867     ):
1868     """Handle encoding and maximum line length based on config."""
1869
1870     def __init__(self, config, **kw):
1871         strict_utf8 = config.get_bool('emailstrictutf8', default=None)
1872         if strict_utf8 is not None:
1873             kw['strict_utf8'] = strict_utf8
1874
1875         emailmaxlinelength = config.get('emailmaxlinelength')
1876         if emailmaxlinelength is not None:
1877             kw['emailmaxlinelength'] = int(emailmaxlinelength)
1878
1879         super(ConfigFilterLinesEnvironmentMixin, self).__init__(
1880             config=config, **kw
1881             )
1882
1883
1884 class MaxlinesEnvironmentMixin(Environment):
1885     """Limit the email body to a specified number of lines."""
1886
1887     def __init__(self, emailmaxlines, **kw):
1888         super(MaxlinesEnvironmentMixin, self).__init__(**kw)
1889         self.__emailmaxlines = emailmaxlines
1890
1891     def filter_body(self, lines):
1892         lines = super(MaxlinesEnvironmentMixin, self).filter_body(lines)
1893         if self.__emailmaxlines:
1894             lines = limit_lines(lines, self.__emailmaxlines)
1895         return lines
1896
1897
1898 class ConfigMaxlinesEnvironmentMixin(
1899     ConfigEnvironmentMixin,
1900     MaxlinesEnvironmentMixin,
1901     ):
1902     """Limit the email body to the number of lines specified in config."""
1903
1904     def __init__(self, config, **kw):
1905         emailmaxlines = int(config.get('emailmaxlines', default='0'))
1906         super(ConfigMaxlinesEnvironmentMixin, self).__init__(
1907             config=config,
1908             emailmaxlines=emailmaxlines,
1909             **kw
1910             )
1911
1912
1913 class FQDNEnvironmentMixin(Environment):
1914     """A mixin that sets the host's FQDN to its constructor argument."""
1915
1916     def __init__(self, fqdn, **kw):
1917         super(FQDNEnvironmentMixin, self).__init__(**kw)
1918         self.COMPUTED_KEYS += ['fqdn']
1919         self.__fqdn = fqdn
1920
1921     def get_fqdn(self):
1922         """Return the fully-qualified domain name for this host.
1923
1924         Return None if it is unavailable or unwanted."""
1925
1926         return self.__fqdn
1927
1928
1929 class ConfigFQDNEnvironmentMixin(
1930     ConfigEnvironmentMixin,
1931     FQDNEnvironmentMixin,
1932     ):
1933     """Read the FQDN from the config."""
1934
1935     def __init__(self, config, **kw):
1936         fqdn = config.get('fqdn')
1937         super(ConfigFQDNEnvironmentMixin, self).__init__(
1938             config=config,
1939             fqdn=fqdn,
1940             **kw
1941             )
1942
1943
1944 class ComputeFQDNEnvironmentMixin(FQDNEnvironmentMixin):
1945     """Get the FQDN by calling socket.getfqdn()."""
1946
1947     def __init__(self, **kw):
1948         super(ComputeFQDNEnvironmentMixin, self).__init__(
1949             fqdn=socket.getfqdn(),
1950             **kw
1951             )
1952
1953
1954 class PusherDomainEnvironmentMixin(ConfigEnvironmentMixin):
1955     """Deduce pusher_email from pusher by appending an emaildomain."""
1956
1957     def __init__(self, **kw):
1958         super(PusherDomainEnvironmentMixin, self).__init__(**kw)
1959         self.__emaildomain = self.config.get('emaildomain')
1960
1961     def get_pusher_email(self):
1962         if self.__emaildomain:
1963             # Derive the pusher's full email address in the default way:
1964             return '%s@%s' % (self.get_pusher(), self.__emaildomain)
1965         else:
1966             return super(PusherDomainEnvironmentMixin, self).get_pusher_email()
1967
1968
1969 class StaticRecipientsEnvironmentMixin(Environment):
1970     """Set recipients statically based on constructor parameters."""
1971
1972     def __init__(
1973         self,
1974         refchange_recipients, announce_recipients, revision_recipients,
1975         **kw
1976         ):
1977         super(StaticRecipientsEnvironmentMixin, self).__init__(**kw)
1978
1979         # The recipients for various types of notification emails, as
1980         # RFC 2822 email addresses separated by commas (or the empty
1981         # string if no recipients are configured).  Although there is
1982         # a mechanism to choose the recipient lists based on on the
1983         # actual *contents* of the change being reported, we only
1984         # choose based on the *type* of the change.  Therefore we can
1985         # compute them once and for all:
1986         if not (refchange_recipients
1987                 or announce_recipients
1988                 or revision_recipients):
1989             raise ConfigurationException('No email recipients configured!')
1990         self.__refchange_recipients = refchange_recipients
1991         self.__announce_recipients = announce_recipients
1992         self.__revision_recipients = revision_recipients
1993
1994     def get_refchange_recipients(self, refchange):
1995         return self.__refchange_recipients
1996
1997     def get_announce_recipients(self, annotated_tag_change):
1998         return self.__announce_recipients
1999
2000     def get_revision_recipients(self, revision):
2001         return self.__revision_recipients
2002
2003
2004 class ConfigRecipientsEnvironmentMixin(
2005     ConfigEnvironmentMixin,
2006     StaticRecipientsEnvironmentMixin
2007     ):
2008     """Determine recipients statically based on config."""
2009
2010     def __init__(self, config, **kw):
2011         super(ConfigRecipientsEnvironmentMixin, self).__init__(
2012             config=config,
2013             refchange_recipients=self._get_recipients(
2014                 config, 'refchangelist', 'mailinglist',
2015                 ),
2016             announce_recipients=self._get_recipients(
2017                 config, 'announcelist', 'refchangelist', 'mailinglist',
2018                 ),
2019             revision_recipients=self._get_recipients(
2020                 config, 'commitlist', 'mailinglist',
2021                 ),
2022             **kw
2023             )
2024
2025     def _get_recipients(self, config, *names):
2026         """Return the recipients for a particular type of message.
2027
2028         Return the list of email addresses to which a particular type
2029         of notification email should be sent, by looking at the config
2030         value for "multimailhook.$name" for each of names.  Use the
2031         value from the first name that is configured.  The return
2032         value is a (possibly empty) string containing RFC 2822 email
2033         addresses separated by commas.  If no configuration could be
2034         found, raise a ConfigurationException."""
2035
2036         for name in names:
2037             retval = config.get_recipients(name)
2038             if retval is not None:
2039                 return retval
2040         else:
2041             return ''
2042
2043
2044 class ProjectdescEnvironmentMixin(Environment):
2045     """Make a "projectdesc" value available for templates.
2046
2047     By default, it is set to the first line of $GIT_DIR/description
2048     (if that file is present and appears to be set meaningfully)."""
2049
2050     def __init__(self, **kw):
2051         super(ProjectdescEnvironmentMixin, self).__init__(**kw)
2052         self.COMPUTED_KEYS += ['projectdesc']
2053
2054     def get_projectdesc(self):
2055         """Return a one-line descripition of the project."""
2056
2057         git_dir = get_git_dir()
2058         try:
2059             projectdesc = open(os.path.join(git_dir, 'description')).readline().strip()
2060             if projectdesc and not projectdesc.startswith('Unnamed repository'):
2061                 return projectdesc
2062         except IOError:
2063             pass
2064
2065         return 'UNNAMED PROJECT'
2066
2067
2068 class GenericEnvironmentMixin(Environment):
2069     def get_pusher(self):
2070         return self.osenv.get('USER', 'unknown user')
2071
2072
2073 class GenericEnvironment(
2074     ProjectdescEnvironmentMixin,
2075     ConfigMaxlinesEnvironmentMixin,
2076     ComputeFQDNEnvironmentMixin,
2077     ConfigFilterLinesEnvironmentMixin,
2078     ConfigRecipientsEnvironmentMixin,
2079     PusherDomainEnvironmentMixin,
2080     ConfigOptionsEnvironmentMixin,
2081     GenericEnvironmentMixin,
2082     Environment,
2083     ):
2084     pass
2085
2086
2087 class GitoliteEnvironmentMixin(Environment):
2088     def get_repo_shortname(self):
2089         # The gitolite environment variable $GL_REPO is a pretty good
2090         # repo_shortname (though it's probably not as good as a value
2091         # the user might have explicitly put in his config).
2092         return (
2093             self.osenv.get('GL_REPO', None)
2094             or super(GitoliteEnvironmentMixin, self).get_repo_shortname()
2095             )
2096
2097     def get_pusher(self):
2098         return self.osenv.get('GL_USER', 'unknown user')
2099
2100
2101 class IncrementalDateTime(object):
2102     """Simple wrapper to give incremental date/times.
2103
2104     Each call will result in a date/time a second later than the
2105     previous call.  This can be used to falsify email headers, to
2106     increase the likelihood that email clients sort the emails
2107     correctly."""
2108
2109     def __init__(self):
2110         self.time = time.time()
2111
2112     def next(self):
2113         formatted = formatdate(self.time, True)
2114         self.time += 1
2115         return formatted
2116
2117
2118 class GitoliteEnvironment(
2119     ProjectdescEnvironmentMixin,
2120     ConfigMaxlinesEnvironmentMixin,
2121     ComputeFQDNEnvironmentMixin,
2122     ConfigFilterLinesEnvironmentMixin,
2123     ConfigRecipientsEnvironmentMixin,
2124     PusherDomainEnvironmentMixin,
2125     ConfigOptionsEnvironmentMixin,
2126     GitoliteEnvironmentMixin,
2127     Environment,
2128     ):
2129     pass
2130
2131
2132 class Push(object):
2133     """Represent an entire push (i.e., a group of ReferenceChanges).
2134
2135     It is easy to figure out what commits were added to a *branch* by
2136     a Reference change:
2137
2138         git rev-list change.old..change.new
2139
2140     or removed from a *branch*:
2141
2142         git rev-list change.new..change.old
2143
2144     But it is not quite so trivial to determine which entirely new
2145     commits were added to the *repository* by a push and which old
2146     commits were discarded by a push.  A big part of the job of this
2147     class is to figure out these things, and to make sure that new
2148     commits are only detailed once even if they were added to multiple
2149     references.
2150
2151     The first step is to determine the "other" references--those
2152     unaffected by the current push.  They are computed by
2153     Push._compute_other_ref_sha1s() by listing all references then
2154     removing any affected by this push.
2155
2156     The commits contained in the repository before this push were
2157
2158         git rev-list other1 other2 other3 ... change1.old change2.old ...
2159
2160     Where "changeN.old" is the old value of one of the references
2161     affected by this push.
2162
2163     The commits contained in the repository after this push are
2164
2165         git rev-list other1 other2 other3 ... change1.new change2.new ...
2166
2167     The commits added by this push are the difference between these
2168     two sets, which can be written
2169
2170         git rev-list \
2171             ^other1 ^other2 ... \
2172             ^change1.old ^change2.old ... \
2173             change1.new change2.new ...
2174
2175     The commits removed by this push can be computed by
2176
2177         git rev-list \
2178             ^other1 ^other2 ... \
2179             ^change1.new ^change2.new ... \
2180             change1.old change2.old ...
2181
2182     The last point is that it is possible that other pushes are
2183     occurring simultaneously to this one, so reference values can
2184     change at any time.  It is impossible to eliminate all race
2185     conditions, but we reduce the window of time during which problems
2186     can occur by translating reference names to SHA1s as soon as
2187     possible and working with SHA1s thereafter (because SHA1s are
2188     immutable)."""
2189
2190     # A map {(changeclass, changetype) : integer} specifying the order
2191     # that reference changes will be processed if multiple reference
2192     # changes are included in a single push.  The order is significant
2193     # mostly because new commit notifications are threaded together
2194     # with the first reference change that includes the commit.  The
2195     # following order thus causes commits to be grouped with branch
2196     # changes (as opposed to tag changes) if possible.
2197     SORT_ORDER = dict(
2198         (value, i) for (i, value) in enumerate([
2199             (BranchChange, 'update'),
2200             (BranchChange, 'create'),
2201             (AnnotatedTagChange, 'update'),
2202             (AnnotatedTagChange, 'create'),
2203             (NonAnnotatedTagChange, 'update'),
2204             (NonAnnotatedTagChange, 'create'),
2205             (BranchChange, 'delete'),
2206             (AnnotatedTagChange, 'delete'),
2207             (NonAnnotatedTagChange, 'delete'),
2208             (OtherReferenceChange, 'update'),
2209             (OtherReferenceChange, 'create'),
2210             (OtherReferenceChange, 'delete'),
2211             ])
2212         )
2213
2214     def __init__(self, changes):
2215         self.changes = sorted(changes, key=self._sort_key)
2216
2217         # The SHA-1s of commits referred to by references unaffected
2218         # by this push:
2219         other_ref_sha1s = self._compute_other_ref_sha1s()
2220
2221         self._old_rev_exclusion_spec = self._compute_rev_exclusion_spec(
2222             other_ref_sha1s.union(
2223                 change.old.sha1
2224                 for change in self.changes
2225                 if change.old.type in ['commit', 'tag']
2226                 )
2227             )
2228         self._new_rev_exclusion_spec = self._compute_rev_exclusion_spec(
2229             other_ref_sha1s.union(
2230                 change.new.sha1
2231                 for change in self.changes
2232                 if change.new.type in ['commit', 'tag']
2233                 )
2234             )
2235
2236     @classmethod
2237     def _sort_key(klass, change):
2238         return (klass.SORT_ORDER[change.__class__, change.change_type], change.refname,)
2239
2240     def _compute_other_ref_sha1s(self):
2241         """Return the GitObjects referred to by references unaffected by this push."""
2242
2243         # The refnames being changed by this push:
2244         updated_refs = set(
2245             change.refname
2246             for change in self.changes
2247             )
2248
2249         # The SHA-1s of commits referred to by all references in this
2250         # repository *except* updated_refs:
2251         sha1s = set()
2252         fmt = (
2253             '%(objectname) %(objecttype) %(refname)\n'
2254             '%(*objectname) %(*objecttype) %(refname)'
2255             )
2256         for line in read_git_lines(['for-each-ref', '--format=%s' % (fmt,)]):
2257             (sha1, type, name) = line.split(' ', 2)
2258             if sha1 and type == 'commit' and name not in updated_refs:
2259                 sha1s.add(sha1)
2260
2261         return sha1s
2262
2263     def _compute_rev_exclusion_spec(self, sha1s):
2264         """Return an exclusion specification for 'git rev-list'.
2265
2266         git_objects is an iterable over GitObject instances.  Return a
2267         string that can be passed to the standard input of 'git
2268         rev-list --stdin' to exclude all of the commits referred to by
2269         git_objects."""
2270
2271         return ''.join(
2272             ['^%s\n' % (sha1,) for sha1 in sorted(sha1s)]
2273             )
2274
2275     def get_new_commits(self, reference_change=None):
2276         """Return a list of commits added by this push.
2277
2278         Return a list of the object names of commits that were added
2279         by the part of this push represented by reference_change.  If
2280         reference_change is None, then return a list of *all* commits
2281         added by this push."""
2282
2283         if not reference_change:
2284             new_revs = sorted(
2285                 change.new.sha1
2286                 for change in self.changes
2287                 if change.new
2288                 )
2289         elif not reference_change.new.commit_sha1:
2290             return []
2291         else:
2292             new_revs = [reference_change.new.commit_sha1]
2293
2294         cmd = ['rev-list', '--stdin'] + new_revs
2295         return read_git_lines(cmd, input=self._old_rev_exclusion_spec)
2296
2297     def get_discarded_commits(self, reference_change):
2298         """Return a list of commits discarded by this push.
2299
2300         Return a list of the object names of commits that were
2301         entirely discarded from the repository by the part of this
2302         push represented by reference_change."""
2303
2304         if not reference_change.old.commit_sha1:
2305             return []
2306         else:
2307             old_revs = [reference_change.old.commit_sha1]
2308
2309         cmd = ['rev-list', '--stdin'] + old_revs
2310         return read_git_lines(cmd, input=self._new_rev_exclusion_spec)
2311
2312     def send_emails(self, mailer, body_filter=None):
2313         """Use send all of the notification emails needed for this push.
2314
2315         Use send all of the notification emails (including reference
2316         change emails and commit emails) needed for this push.  Send
2317         the emails using mailer.  If body_filter is not None, then use
2318         it to filter the lines that are intended for the email
2319         body."""
2320
2321         # The sha1s of commits that were introduced by this push.
2322         # They will be removed from this set as they are processed, to
2323         # guarantee that one (and only one) email is generated for
2324         # each new commit.
2325         unhandled_sha1s = set(self.get_new_commits())
2326         send_date = IncrementalDateTime()
2327         for change in self.changes:
2328             # Check if we've got anyone to send to
2329             if not change.recipients:
2330                 sys.stderr.write(
2331                     '*** no recipients configured so no email will be sent\n'
2332                     '*** for %r update %s->%s\n'
2333                     % (change.refname, change.old.sha1, change.new.sha1,)
2334                     )
2335             else:
2336                 sys.stderr.write('Sending notification emails to: %s\n' % (change.recipients,))
2337                 extra_values = {'send_date' : send_date.next()}
2338                 mailer.send(
2339                     change.generate_email(self, body_filter, extra_values),
2340                     change.recipients,
2341                     )
2342
2343             sha1s = []
2344             for sha1 in reversed(list(self.get_new_commits(change))):
2345                 if sha1 in unhandled_sha1s:
2346                     sha1s.append(sha1)
2347                     unhandled_sha1s.remove(sha1)
2348
2349             max_emails = change.environment.maxcommitemails
2350             if max_emails and len(sha1s) > max_emails:
2351                 sys.stderr.write(
2352                     '*** Too many new commits (%d), not sending commit emails.\n' % len(sha1s)
2353                     + '*** Try setting multimailhook.maxCommitEmails to a greater value\n'
2354                     + '*** Currently, multimailhook.maxCommitEmails=%d\n' % max_emails
2355                     )
2356                 return
2357
2358             for (num, sha1) in enumerate(sha1s):
2359                 rev = Revision(change, GitObject(sha1), num=num+1, tot=len(sha1s))
2360                 if rev.recipients:
2361                     extra_values = {'send_date' : send_date.next()}
2362                     mailer.send(
2363                         rev.generate_email(self, body_filter, extra_values),
2364                         rev.recipients,
2365                         )
2366
2367         # Consistency check:
2368         if unhandled_sha1s:
2369             sys.stderr.write(
2370                 'ERROR: No emails were sent for the following new commits:\n'
2371                 '    %s\n'
2372                 % ('\n    '.join(sorted(unhandled_sha1s)),)
2373                 )
2374
2375
2376 def run_as_post_receive_hook(environment, mailer):
2377     changes = []
2378     for line in sys.stdin:
2379         (oldrev, newrev, refname) = line.strip().split(' ', 2)
2380         changes.append(
2381             ReferenceChange.create(environment, oldrev, newrev, refname)
2382             )
2383     push = Push(changes)
2384     push.send_emails(mailer, body_filter=environment.filter_body)
2385
2386
2387 def run_as_update_hook(environment, mailer, refname, oldrev, newrev):
2388     changes = [
2389         ReferenceChange.create(
2390             environment,
2391             read_git_output(['rev-parse', '--verify', oldrev]),
2392             read_git_output(['rev-parse', '--verify', newrev]),
2393             refname,
2394             ),
2395         ]
2396     push = Push(changes)
2397     push.send_emails(mailer, body_filter=environment.filter_body)
2398
2399
2400 def choose_mailer(config, environment):
2401     mailer = config.get('mailer', default='sendmail')
2402
2403     if mailer == 'smtp':
2404         smtpserver = config.get('smtpserver', default='localhost')
2405         mailer = SMTPMailer(
2406             envelopesender=(environment.get_sender() or environment.get_fromaddr()),
2407             smtpserver=smtpserver,
2408             )
2409     elif mailer == 'sendmail':
2410         command = config.get('sendmailcommand')
2411         if command:
2412             command = shlex.split(command)
2413         mailer = SendMailer(command=command, envelopesender=environment.get_sender())
2414     else:
2415         sys.stderr.write(
2416             'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n' % mailer
2417             + 'please use one of "smtp" or "sendmail".\n'
2418             )
2419         sys.exit(1)
2420     return mailer
2421
2422
2423 KNOWN_ENVIRONMENTS = {
2424     'generic' : GenericEnvironmentMixin,
2425     'gitolite' : GitoliteEnvironmentMixin,
2426     }
2427
2428
2429 def choose_environment(config, osenv=None, env=None, recipients=None):
2430     if not osenv:
2431         osenv = os.environ
2432
2433     environment_mixins = [
2434         ProjectdescEnvironmentMixin,
2435         ConfigMaxlinesEnvironmentMixin,
2436         ComputeFQDNEnvironmentMixin,
2437         ConfigFilterLinesEnvironmentMixin,
2438         PusherDomainEnvironmentMixin,
2439         ConfigOptionsEnvironmentMixin,
2440         ]
2441     environment_kw = {
2442         'osenv' : osenv,
2443         'config' : config,
2444         }
2445
2446     if not env:
2447         env = config.get('environment')
2448
2449     if not env:
2450         if 'GL_USER' in osenv and 'GL_REPO' in osenv:
2451             env = 'gitolite'
2452         else:
2453             env = 'generic'
2454
2455     environment_mixins.append(KNOWN_ENVIRONMENTS[env])
2456
2457     if recipients:
2458         environment_mixins.insert(0, StaticRecipientsEnvironmentMixin)
2459         environment_kw['refchange_recipients'] = recipients
2460         environment_kw['announce_recipients'] = recipients
2461         environment_kw['revision_recipients'] = recipients
2462     else:
2463         environment_mixins.insert(0, ConfigRecipientsEnvironmentMixin)
2464
2465     environment_klass = type(
2466         'EffectiveEnvironment',
2467         tuple(environment_mixins) + (Environment,),
2468         {},
2469         )
2470     return environment_klass(**environment_kw)
2471
2472
2473 def main(args):
2474     parser = optparse.OptionParser(
2475         description=__doc__,
2476         usage='%prog [OPTIONS]\n   or: %prog [OPTIONS] REFNAME OLDREV NEWREV',
2477         )
2478
2479     parser.add_option(
2480         '--environment', '--env', action='store', type='choice',
2481         choices=['generic', 'gitolite'], default=None,
2482         help=(
2483             'Choose type of environment is in use.  Default is taken from '
2484             'multimailhook.environment if set; otherwise "generic".'
2485             ),
2486         )
2487     parser.add_option(
2488         '--stdout', action='store_true', default=False,
2489         help='Output emails to stdout rather than sending them.',
2490         )
2491     parser.add_option(
2492         '--recipients', action='store', default=None,
2493         help='Set list of email recipients for all types of emails.',
2494         )
2495     parser.add_option(
2496         '--show-env', action='store_true', default=False,
2497         help=(
2498             'Write to stderr the values determined for the environment '
2499             '(intended for debugging purposes).'
2500             ),
2501         )
2502
2503     (options, args) = parser.parse_args(args)
2504
2505     config = Config('multimailhook')
2506
2507     try:
2508         environment = choose_environment(
2509             config, osenv=os.environ,
2510             env=options.environment,
2511             recipients=options.recipients,
2512             )
2513
2514         if options.show_env:
2515             sys.stderr.write('Environment values:\n')
2516             for (k,v) in sorted(environment.get_values().items()):
2517                 sys.stderr.write('    %s : %r\n' % (k,v))
2518             sys.stderr.write('\n')
2519
2520         if options.stdout:
2521             mailer = OutputMailer(sys.stdout)
2522         else:
2523             mailer = choose_mailer(config, environment)
2524
2525         # Dual mode: if arguments were specified on the command line, run
2526         # like an update hook; otherwise, run as a post-receive hook.
2527         if args:
2528             if len(args) != 3:
2529                 parser.error('Need zero or three non-option arguments')
2530             (refname, oldrev, newrev) = args
2531             run_as_update_hook(environment, mailer, refname, oldrev, newrev)
2532         else:
2533             run_as_post_receive_hook(environment, mailer)
2534     except ConfigurationException, e:
2535         sys.exit(str(e))
2536
2537
2538 if __name__ == '__main__':
2539     main(sys.argv[1:])