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:])