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