/[adm]/puppet/deployment/mgagit/templates/git_multimail.py
ViewVC logotype

Annotation of /puppet/deployment/mgagit/templates/git_multimail.py

Parent Directory Parent Directory | Revision Log Revision Log


Revision 3354 - (hide annotations) (download) (as text)
Sun Oct 6 22:41:25 2013 UTC (10 years, 6 months ago) by colin
File MIME type: text/x-python
File size: 84678 byte(s)
mgagit: Add new templates for generationg per-commit notification emails.

This uses the git multimail project from upstream revision 3fcd7bffef
which is the master revision at the time of writing.

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

Properties

Name Value
svn:eol-style native
svn:executable *
svn:mime-type text/x-python

  ViewVC Help
Powered by ViewVC 1.1.30