1
#! /usr/bin/env python2
2
3
+# Copyright (c) 2015 Matthieu Moy and others
4
# Copyright (c) 2012-2014 Michael Haggerty and others
5
# Derived from contrib/hooks/post-receive-email, which is
6
# Copyright (c) 2007 Andy Parkins
100
' (was %(oldrev_short)s)'
101
)
102
103
+COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE = (
104
+ '%(emailprefix)s%(refname_type)s %(short_refname)s updated: %(oneline)s'
105
+ )
106
+
107
REFCHANGE_HEADER_TEMPLATE = """\
108
Date: %(send_date)s
109
To: %(recipients)s
235
REVISION_HEADER_TEMPLATE = """\
236
Date: %(send_date)s
237
To: %(recipients)s
238
+Cc: %(cc_recipients)s
239
Subject: %(emailprefix)s%(num)02d/%(tot)02d: %(oneline)s
240
MIME-Version: 1.0
241
Content-Type: text/plain; charset=%(charset)s
264
REVISION_FOOTER_TEMPLATE = FOOTER_TEMPLATE
265
266
267
+# Combined, meaning refchange+revision email (for single-commit additions)
268
+COMBINED_HEADER_TEMPLATE = """\
269
+Date: %(send_date)s
270
+To: %(recipients)s
271
+Subject: %(subject)s
272
+MIME-Version: 1.0
273
+Content-Type: text/plain; charset=%(charset)s
274
+Content-Transfer-Encoding: 8bit
275
+Message-ID: %(msgid)s
276
+From: %(fromaddr)s
277
+Reply-To: %(reply_to)s
278
+X-Git-Host: %(fqdn)s
279
+X-Git-Repo: %(repo_shortname)s
280
+X-Git-Refname: %(refname)s
281
+X-Git-Reftype: %(refname_type)s
282
+X-Git-Oldrev: %(oldrev)s
283
+X-Git-Newrev: %(newrev)s
284
+X-Git-Rev: %(rev)s
285
+Auto-Submitted: auto-generated
286
+"""
287
+
288
+COMBINED_INTRO_TEMPLATE = """\
289
+This is an automated email from the git hooks/post-receive script.
290
+
291
+%(pusher)s pushed a commit to %(refname_type)s %(short_refname)s
292
+in repository %(repo_shortname)s.
293
+
294
+"""
295
+
296
+COMBINED_FOOTER_TEMPLATE = FOOTER_TEMPLATE
297
+
298
+
299
class CommandError(Exception):
300
def __init__(self, cmd, retcode):
301
self.cmd = cmd
374
return read_git_output(args, keepends=True, **kw).splitlines(keepends)
375
376
377
+def git_rev_list_ish(cmd, spec, args=None, **kw):
378
+ """Common functionality for invoking a 'git rev-list'-like command.
379
+
380
+ Parameters:
381
+ * cmd is the Git command to run, e.g., 'rev-list' or 'log'.
382
+ * spec is a list of revision arguments to pass to the named
383
+ command. If None, this function returns an empty list.
384
+ * args is a list of extra arguments passed to the named command.
385
+ * All other keyword arguments (if any) are passed to the
386
+ underlying read_git_lines() function.
387
+
388
+ Return the output of the Git command in the form of a list, one
389
+ entry per output line.
390
+ """
391
+ if spec is None:
392
+ return []
393
+ if args is None:
394
+ args = []
395
+ args = [cmd, '--stdin'] + args
396
+ spec_stdin = ''.join(s + '\n' for s in spec)
397
+ return read_git_lines(args, input=spec_stdin, **kw)
398
+
399
+
400
+def git_rev_list(spec, **kw):
401
+ """Run 'git rev-list' with the given list of revision arguments.
402
+
403
+ See git_rev_list_ish() for parameter and return value
404
+ documentation.
405
+ """
406
+ return git_rev_list_ish('rev-list', spec, **kw)
407
+
408
+
409
+def git_log(spec, **kw):
410
+ """Run 'git log' with the given list of revision arguments.
411
+
412
+ See git_rev_list_ish() for parameter and return value
413
+ documentation.
414
+ """
415
+ return git_rev_list_ish('log', spec, **kw)
416
+
417
+
418
def header_encode(text, header_name=None):
419
"""Encode and line-wrap the value of an email header field."""
420
467
def get(self, name, default=None):
468
try:
469
values = self._split(read_git_output(
391
- ['config', '--get', '--null', '%s.%s' % (self.section, name)],
392
- env=self.env, keepends=True,
393
- ))
470
+ ['config', '--get', '--null', '%s.%s' % (self.section, name)],
471
+ env=self.env, keepends=True,
472
+ ))
473
assert len(values) == 1
474
return values[0]
475
except CommandError:
528
env=self.env,
529
)
530
452
- def has_key(self, name):
531
+ def __contains__(self, name):
532
return self.get_all(name, default=None) is not None
533
534
+ # We don't use this method anymore internally, but keep it here in
535
+ # case somebody is calling it from their own code:
536
+ def has_key(self, name):
537
+ return name in self
538
+
539
def unset_all(self, name):
540
try:
541
read_git_output(
663
self._values = None
664
665
def _compute_values(self):
582
- """Return a dictionary {keyword : expansion} for this Change.
666
+ """Return a dictionary {keyword: expansion} for this Change.
667
668
Derived classes overload this method to add more entries to
669
the return value. This method is used internally by
673
return self.environment.get_values()
674
675
def get_values(self, **extra_values):
592
- """Return a dictionary {keyword : expansion} for this Change.
676
+ """Return a dictionary {keyword: expansion} for this Change.
677
678
Return a dictionary mapping keywords to the values that they
679
should be expanded to for this Change (used when interpolating
720
value = value % values
721
except KeyError, e:
722
if DEBUG:
639
- sys.stderr.write(
723
+ self.environment.log_warning(
724
'Warning: unknown variable %r in the following line; line skipped:\n'
725
' %s\n'
726
% (e.args[0], line,)
795
class Revision(Change):
796
"""A Change consisting of a single git commit."""
797
798
+ CC_RE = re.compile(r'^\s*C[Cc]:\s*(?P<to>[^#]+@[^\s#]*)\s*(#.*)?$')
799
+
800
def __init__(self, reference_change, rev, num, tot):
801
Change.__init__(self, reference_change.environment)
802
self.reference_change = reference_change
808
self.author = read_git_output(['log', '--no-walk', '--format=%aN <%aE>', self.rev.sha1])
809
self.recipients = self.environment.get_revision_recipients(self)
810
811
+ self.cc_recipients = ''
812
+ if self.environment.get_scancommitforcc():
813
+ self.cc_recipients = ', '.join(to.strip() for to in self._cc_recipients())
814
+ if self.cc_recipients:
815
+ self.environment.log_msg(
816
+ 'Add %s to CC for %s\n' % (self.cc_recipients, self.rev.sha1))
817
+
818
+ def _cc_recipients(self):
819
+ cc_recipients = []
820
+ message = read_git_output(['log', '--no-walk', '--format=%b', self.rev.sha1])
821
+ lines = message.strip().split('\n')
822
+ for line in lines:
823
+ m = re.match(self.CC_RE, line)
824
+ if m:
825
+ cc_recipients.append(m.group('to'))
826
+
827
+ return cc_recipients
828
+
829
def _compute_values(self):
830
values = Change._compute_values(self)
831
843
values['num'] = self.num
844
values['tot'] = self.tot
845
values['recipients'] = self.recipients
846
+ if self.cc_recipients:
847
+ values['cc_recipients'] = self.cc_recipients
848
values['oneline'] = oneline
849
values['author'] = self.author
850
856
857
def generate_email_header(self, **extra_values):
858
for line in self.expand_header_lines(
753
- REVISION_HEADER_TEMPLATE, **extra_values
754
- ):
859
+ REVISION_HEADER_TEMPLATE, **extra_values
860
+ ):
861
yield line
862
863
def generate_email_intro(self):
928
klass = BranchChange
929
elif area == 'remotes':
930
# Tracking branch:
825
- sys.stderr.write(
931
+ environment.log_warning(
932
'*** Push-update of tracking branch %r\n'
933
'*** - incomplete email generated.\n'
828
- % (refname,)
934
+ % (refname,)
935
)
936
klass = OtherReferenceChange
937
else:
938
# Some other reference namespace:
833
- sys.stderr.write(
939
+ environment.log_warning(
940
'*** Push-update of strange reference %r\n'
941
'*** - incomplete email generated.\n'
836
- % (refname,)
942
+ % (refname,)
943
)
944
klass = OtherReferenceChange
945
else:
946
# Anything else (is there anything else?)
841
- sys.stderr.write(
947
+ environment.log_warning(
948
'*** Unknown type of update to %r (%s)\n'
949
'*** - incomplete email generated.\n'
844
- % (refname, rev.type,)
950
+ % (refname, rev.type,)
951
)
952
klass = OtherReferenceChange
953
960
def __init__(self, environment, refname, short_refname, old, new, rev):
961
Change.__init__(self, environment)
962
self.change_type = {
857
- (False, True) : 'create',
858
- (True, True) : 'update',
859
- (True, False) : 'delete',
963
+ (False, True): 'create',
964
+ (True, True): 'update',
965
+ (True, False): 'delete',
966
}[bool(old), bool(new)]
967
self.refname = refname
968
self.short_refname = short_refname
971
self.rev = rev
972
self.msgid = make_msgid()
973
self.diffopts = environment.diffopts
974
+ self.graphopts = environment.graphopts
975
self.logopts = environment.logopts
976
self.commitlogopts = environment.commitlogopts
977
+ self.showgraph = environment.refchange_showgraph
978
self.showlog = environment.refchange_showlog
979
980
+ self.header_template = REFCHANGE_HEADER_TEMPLATE
981
+ self.intro_template = REFCHANGE_INTRO_TEMPLATE
982
+ self.footer_template = FOOTER_TEMPLATE
983
+
984
def _compute_values(self):
985
values = Change._compute_values(self)
986
1006
1007
return values
1008
1009
+ def send_single_combined_email(self, known_added_sha1s):
1010
+ """Determine if a combined refchange/revision email should be sent
1011
+
1012
+ If there is only a single new (non-merge) commit added by a
1013
+ change, it is useful to combine the ReferenceChange and
1014
+ Revision emails into one. In such a case, return the single
1015
+ revision; otherwise, return None.
1016
+
1017
+ This method is overridden in BranchChange."""
1018
+
1019
+ return None
1020
+
1021
+ def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):
1022
+ """Generate an email describing this change AND specified revision.
1023
+
1024
+ Iterate over the lines (including the header lines) of an
1025
+ email describing this change. If body_filter is not None,
1026
+ then use it to filter the lines that are intended for the
1027
+ email body.
1028
+
1029
+ The extra_header_values field is received as a dict and not as
1030
+ **kwargs, to allow passing other keyword arguments in the
1031
+ future (e.g. passing extra values to generate_email_intro()
1032
+
1033
+ This method is overridden in BranchChange."""
1034
+
1035
+ raise NotImplementedError
1036
+
1037
def get_subject(self):
1038
template = {
899
- 'create' : REF_CREATED_SUBJECT_TEMPLATE,
900
- 'update' : REF_UPDATED_SUBJECT_TEMPLATE,
901
- 'delete' : REF_DELETED_SUBJECT_TEMPLATE,
1039
+ 'create': REF_CREATED_SUBJECT_TEMPLATE,
1040
+ 'update': REF_UPDATED_SUBJECT_TEMPLATE,
1041
+ 'delete': REF_DELETED_SUBJECT_TEMPLATE,
1042
}[self.change_type]
1043
return self.expand(template)
1044
1047
extra_values['subject'] = self.get_subject()
1048
1049
for line in self.expand_header_lines(
910
- REFCHANGE_HEADER_TEMPLATE, **extra_values
911
- ):
1050
+ self.header_template, **extra_values
1051
+ ):
1052
yield line
1053
1054
def generate_email_intro(self):
915
- for line in self.expand_lines(REFCHANGE_INTRO_TEMPLATE):
1055
+ for line in self.expand_lines(self.intro_template):
1056
yield line
1057
1058
def generate_email_body(self, push):
1062
generate_update_summary() / generate_delete_summary()."""
1063
1064
change_summary = {
925
- 'create' : self.generate_create_summary,
926
- 'delete' : self.generate_delete_summary,
927
- 'update' : self.generate_update_summary,
1065
+ 'create': self.generate_create_summary,
1066
+ 'delete': self.generate_delete_summary,
1067
+ 'update': self.generate_update_summary,
1068
}[self.change_type](push)
1069
for line in change_summary:
1070
yield line
1073
yield line
1074
1075
def generate_email_footer(self):
936
- return self.expand_lines(FOOTER_TEMPLATE)
1076
+ return self.expand_lines(self.footer_template)
1077
+
1078
+ def generate_revision_change_graph(self, push):
1079
+ if self.showgraph:
1080
+ args = ['--graph'] + self.graphopts
1081
+ for newold in ('new', 'old'):
1082
+ has_newold = False
1083
+ spec = push.get_commits_spec(newold, self)
1084
+ for line in git_log(spec, args=args, keepends=True):
1085
+ if not has_newold:
1086
+ has_newold = True
1087
+ yield '\n'
1088
+ yield 'Graph of %s commits:\n\n' % (
1089
+ {'new': 'new', 'old': 'discarded'}[newold],)
1090
+ yield ' ' + line
1091
+ if has_newold:
1092
+ yield '\n'
1093
1094
def generate_revision_change_log(self, new_commits_list):
1095
if self.showlog:
1101
+ new_commits_list
1102
+ ['--'],
1103
keepends=True,
948
- ):
1104
+ ):
1105
yield line
1106
1107
+ def generate_new_revision_summary(self, tot, new_commits_list, push):
1108
+ for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot):
1109
+ yield line
1110
+ for line in self.generate_revision_change_graph(push):
1111
+ yield line
1112
+ for line in self.generate_revision_change_log(new_commits_list):
1113
+ yield line
1114
+
1115
def generate_revision_change_summary(self, push):
1116
"""Generate a summary of the revisions added/removed by this change."""
1117
1124
sha1s.reverse()
1125
tot = len(sha1s)
1126
new_revisions = [
963
- Revision(self, GitObject(sha1), num=i+1, tot=tot)
1127
+ Revision(self, GitObject(sha1), num=i + 1, tot=tot)
1128
for (i, sha1) in enumerate(sha1s)
1129
]
1130
1137
BRIEF_SUMMARY_TEMPLATE, action='new', text=subject,
1138
)
1139
yield '\n'
976
- for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=tot):
977
- yield line
978
- for line in self.generate_revision_change_log([r.rev.sha1 for r in new_revisions]):
1140
+ for line in self.generate_new_revision_summary(
1141
+ tot, [r.rev.sha1 for r in new_revisions], push):
1142
yield line
1143
else:
1144
for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
1156
# revisions in the summary even though we will not send
1157
# new notification emails for them.
1158
adds = list(generate_summaries(
996
- '--topo-order', '--reverse', '%s..%s'
997
- % (self.old.commit_sha1, self.new.commit_sha1,)
998
- ))
1159
+ '--topo-order', '--reverse', '%s..%s'
1160
+ % (self.old.commit_sha1, self.new.commit_sha1,)
1161
+ ))
1162
1163
# List of the revisions that were removed from the branch
1164
# by this update. This will be empty except for
1165
# non-fast-forward updates.
1166
discards = list(generate_summaries(
1004
- '%s..%s' % (self.new.commit_sha1, self.old.commit_sha1,)
1005
- ))
1167
+ '%s..%s' % (self.new.commit_sha1, self.old.commit_sha1,)
1168
+ ))
1169
1170
if adds:
1171
new_commits_list = push.get_new_commits(self)
1234
yield '\n'
1235
1236
if new_commits:
1074
- for line in self.expand_lines(NEW_REVISIONS_TEMPLATE, tot=len(new_commits)):
1075
- yield line
1076
- for line in self.generate_revision_change_log(new_commits_list):
1237
+ for line in self.generate_new_revision_summary(
1238
+ len(new_commits), new_commits_list, push):
1239
yield line
1240
else:
1241
for line in self.expand_lines(NO_NEW_REVISIONS_TEMPLATE):
1242
yield line
1243
+ for line in self.generate_revision_change_graph(push):
1244
+ yield line
1245
1246
# The diffstat is shown from the old revision to the new
1247
# revision. This is to show the truth of what happened in
1253
yield '\n'
1254
yield 'Summary of changes:\n'
1255
for line in read_git_lines(
1092
- ['diff-tree']
1093
- + self.diffopts
1094
- + ['%s..%s' % (self.old.commit_sha1, self.new.commit_sha1,)],
1095
- keepends=True,
1096
- ):
1256
+ ['diff-tree']
1257
+ + self.diffopts
1258
+ + ['%s..%s' % (self.old.commit_sha1, self.new.commit_sha1,)],
1259
+ keepends=True,
1260
+ ):
1261
yield line
1262
1263
elif self.old.commit_sha1 and not self.new.commit_sha1:
1267
sha1s = list(push.get_discarded_commits(self))
1268
tot = len(sha1s)
1269
discarded_revisions = [
1106
- Revision(self, GitObject(sha1), num=i+1, tot=tot)
1270
+ Revision(self, GitObject(sha1), num=i + 1, tot=tot)
1271
for (i, sha1) in enumerate(sha1s)
1272
]
1273
1280
yield r.expand(
1281
BRIEF_SUMMARY_TEMPLATE, action='discards', text=subject,
1282
)
1283
+ for line in self.generate_revision_change_graph(push):
1284
+ yield line
1285
else:
1286
for line in self.expand_lines(NO_DISCARDED_REVISIONS_TEMPLATE):
1287
yield line
1327
old=old, new=new, rev=rev,
1328
)
1329
self.recipients = environment.get_refchange_recipients(self)
1330
+ self._single_revision = None
1331
+
1332
+ def send_single_combined_email(self, known_added_sha1s):
1333
+ if not self.environment.combine_when_single_commit:
1334
+ return None
1335
+
1336
+ # In the sadly-all-too-frequent usecase of people pushing only
1337
+ # one of their commits at a time to a repository, users feel
1338
+ # the reference change summary emails are noise rather than
1339
+ # important signal. This is because, in this particular
1340
+ # usecase, there is a reference change summary email for each
1341
+ # new commit, and all these summaries do is point out that
1342
+ # there is one new commit (which can readily be inferred by
1343
+ # the existence of the individual revision email that is also
1344
+ # sent). In such cases, our users prefer there to be a combined
1345
+ # reference change summary/new revision email.
1346
+ #
1347
+ # So, if the change is an update and it doesn't discard any
1348
+ # commits, and it adds exactly one non-merge commit (gerrit
1349
+ # forces a workflow where every commit is individually merged
1350
+ # and the git-multimail hook fired off for just this one
1351
+ # change), then we send a combined refchange/revision email.
1352
+ try:
1353
+ # If this change is a reference update that doesn't discard
1354
+ # any commits...
1355
+ if self.change_type != 'update':
1356
+ return None
1357
+
1358
+ if read_git_lines(
1359
+ ['merge-base', self.old.sha1, self.new.sha1]
1360
+ ) != [self.old.sha1]:
1361
+ return None
1362
+
1363
+ # Check if this update introduced exactly one non-merge
1364
+ # commit:
1365
+
1366
+ def split_line(line):
1367
+ """Split line into (sha1, [parent,...])."""
1368
+
1369
+ words = line.split()
1370
+ return (words[0], words[1:])
1371
+
1372
+ # Get the new commits introduced by the push as a list of
1373
+ # (sha1, [parent,...])
1374
+ new_commits = [
1375
+ split_line(line)
1376
+ for line in read_git_lines(
1377
+ [
1378
+ 'log', '-3', '--format=%H %P',
1379
+ '%s..%s' % (self.old.sha1, self.new.sha1),
1380
+ ]
1381
+ )
1382
+ ]
1383
+
1384
+ if not new_commits:
1385
+ return None
1386
+
1387
+ # If the newest commit is a merge, save it for a later check
1388
+ # but otherwise ignore it
1389
+ merge = None
1390
+ tot = len(new_commits)
1391
+ if len(new_commits[0][1]) > 1:
1392
+ merge = new_commits[0][0]
1393
+ del new_commits[0]
1394
+
1395
+ # Our primary check: we can't combine if more than one commit
1396
+ # is introduced. We also currently only combine if the new
1397
+ # commit is a non-merge commit, though it may make sense to
1398
+ # combine if it is a merge as well.
1399
+ if not (
1400
+ len(new_commits) == 1
1401
+ and len(new_commits[0][1]) == 1
1402
+ and new_commits[0][0] in known_added_sha1s
1403
+ ):
1404
+ return None
1405
+
1406
+ # We do not want to combine revision and refchange emails if
1407
+ # those go to separate locations.
1408
+ rev = Revision(self, GitObject(new_commits[0][0]), 1, tot)
1409
+ if rev.recipients != self.recipients:
1410
+ return None
1411
+
1412
+ # We ignored the newest commit if it was just a merge of the one
1413
+ # commit being introduced. But we don't want to ignore that
1414
+ # merge commit it it involved conflict resolutions. Check that.
1415
+ if merge and merge != read_git_output(['diff-tree', '--cc', merge]):
1416
+ return None
1417
+
1418
+ # We can combine the refchange and one new revision emails
1419
+ # into one. Return the Revision that a combined email should
1420
+ # be sent about.
1421
+ return rev
1422
+ except CommandError:
1423
+ # Cannot determine number of commits in old..new or new..old;
1424
+ # don't combine reference/revision emails:
1425
+ return None
1426
+
1427
+ def generate_combined_email(self, push, revision, body_filter=None, extra_header_values={}):
1428
+ values = revision.get_values()
1429
+ if extra_header_values:
1430
+ values.update(extra_header_values)
1431
+ if 'subject' not in extra_header_values:
1432
+ values['subject'] = self.expand(COMBINED_REFCHANGE_REVISION_SUBJECT_TEMPLATE, **values)
1433
+
1434
+ self._single_revision = revision
1435
+ self.header_template = COMBINED_HEADER_TEMPLATE
1436
+ self.intro_template = COMBINED_INTRO_TEMPLATE
1437
+ self.footer_template = COMBINED_FOOTER_TEMPLATE
1438
+ for line in self.generate_email(push, body_filter, values):
1439
+ yield line
1440
+
1441
+ def generate_email_body(self, push):
1442
+ '''Call the appropriate body generation routine.
1443
+
1444
+ If this is a combined refchange/revision email, the special logic
1445
+ for handling this combined email comes from this function. For
1446
+ other cases, we just use the normal handling.'''
1447
+
1448
+ # If self._single_revision isn't set; don't override
1449
+ if not self._single_revision:
1450
+ for line in super(BranchChange, self).generate_email_body(push):
1451
+ yield line
1452
+ return
1453
+
1454
+ # This is a combined refchange/revision email; we first provide
1455
+ # some info from the refchange portion, and then call the revision
1456
+ # generate_email_body function to handle the revision portion.
1457
+ adds = list(generate_summaries(
1458
+ '--topo-order', '--reverse', '%s..%s'
1459
+ % (self.old.commit_sha1, self.new.commit_sha1,)
1460
+ ))
1461
+
1462
+ yield self.expand("The following commit(s) were added to %(refname)s by this push:\n")
1463
+ for (sha1, subject) in adds:
1464
+ yield self.expand(
1465
+ BRIEF_SUMMARY_TEMPLATE, action='new',
1466
+ rev_short=sha1, text=subject,
1467
+ )
1468
+
1469
+ yield self._single_revision.rev.short + " is described below\n"
1470
+ yield '\n'
1471
+
1472
+ for line in self._single_revision.generate_email_body(push):
1473
+ yield line
1474
1475
1476
class AnnotatedTagChange(ReferenceChange):
1700
sys.exit(1)
1701
try:
1702
p.stdin.writelines(lines)
1393
- except:
1703
+ except Exception, e:
1704
sys.stderr.write(
1705
'*** Error while generating commit email\n'
1706
'*** - mail sending aborted.\n'
1707
)
1398
- p.terminate()
1399
- raise
1708
+ try:
1709
+ # subprocess.terminate() is not available in Python 2.4
1710
+ p.terminate()
1711
+ except AttributeError:
1712
+ pass
1713
+ raise e
1714
else:
1715
p.stdin.close()
1716
retcode = p.wait()
1721
class SMTPMailer(Mailer):
1722
"""Send emails using Python's smtplib."""
1723
1410
- def __init__(self, envelopesender, smtpserver):
1724
+ def __init__(self, envelopesender, smtpserver,
1725
+ smtpservertimeout=10.0, smtpserverdebuglevel=0,
1726
+ smtpencryption='none',
1727
+ smtpuser='', smtppass='',
1728
+ ):
1729
if not envelopesender:
1730
sys.stderr.write(
1731
'fatal: git_multimail: cannot use SMTPMailer without a sender address.\n'
1732
'please set either multimailhook.envelopeSender or user.email\n'
1733
)
1734
sys.exit(1)
1735
+ if smtpencryption == 'ssl' and not (smtpuser and smtppass):
1736
+ raise ConfigurationException(
1737
+ 'Cannot use SMTPMailer with security option ssl '
1738
+ 'without options username and password.'
1739
+ )
1740
self.envelopesender = envelopesender
1741
self.smtpserver = smtpserver
1742
+ self.smtpservertimeout = smtpservertimeout
1743
+ self.smtpserverdebuglevel = smtpserverdebuglevel
1744
+ self.security = smtpencryption
1745
+ self.username = smtpuser
1746
+ self.password = smtppass
1747
try:
1420
- self.smtp = smtplib.SMTP(self.smtpserver)
1748
+ if self.security == 'none':
1749
+ self.smtp = smtplib.SMTP(self.smtpserver, timeout=self.smtpservertimeout)
1750
+ elif self.security == 'ssl':
1751
+ self.smtp = smtplib.SMTP_SSL(self.smtpserver, timeout=self.smtpservertimeout)
1752
+ elif self.security == 'tls':
1753
+ if ':' not in self.smtpserver:
1754
+ self.smtpserver += ':587' # default port for TLS
1755
+ self.smtp = smtplib.SMTP(self.smtpserver, timeout=self.smtpservertimeout)
1756
+ self.smtp.ehlo()
1757
+ self.smtp.starttls()
1758
+ self.smtp.ehlo()
1759
+ else:
1760
+ sys.stdout.write('*** Error: Control reached an invalid option. ***')
1761
+ sys.exit(1)
1762
+ if self.smtpserverdebuglevel > 0:
1763
+ sys.stdout.write(
1764
+ "*** Setting debug on for SMTP server connection (%s) ***\n"
1765
+ % self.smtpserverdebuglevel)
1766
+ self.smtp.set_debuglevel(self.smtpserverdebuglevel)
1767
except Exception, e:
1422
- sys.stderr.write('*** Error establishing SMTP connection to %s***\n' % self.smtpserver)
1768
+ sys.stderr.write(
1769
+ '*** Error establishing SMTP connection to %s ***\n'
1770
+ % self.smtpserver)
1771
sys.stderr.write('*** %s\n' % str(e))
1772
sys.exit(1)
1773
1774
def __del__(self):
1427
- self.smtp.quit()
1775
+ if hasattr(self, 'smtp'):
1776
+ self.smtp.quit()
1777
1778
def send(self, lines, to_addrs):
1779
try:
1780
+ if self.username or self.password:
1781
+ sys.stderr.write("*** Authenticating as %s ***\n" % self.username)
1782
+ self.smtp.login(self.username, self.password)
1783
msg = ''.join(lines)
1784
# turn comma-separated list into Python list if needed.
1785
if isinstance(to_addrs, basestring):
1786
to_addrs = [email for (name, email) in getaddresses([to_addrs])]
1787
self.smtp.sendmail(self.envelopesender, to_addrs, msg)
1788
except Exception, e:
1437
- sys.stderr.write('*** Error sending email***\n')
1789
+ sys.stderr.write('*** Error sending email ***\n')
1790
sys.stderr.write('*** %s\n' % str(e))
1791
self.smtp.quit()
1792
sys.exit(1)
1901
1902
True iff announce emails should include a shortlog.
1903
1904
+ refchange_showgraph (bool)
1905
+
1906
+ True iff refchanges emails should include a detailed graph.
1907
+
1908
refchange_showlog (bool)
1909
1910
True iff refchanges emails should include a detailed log.
1915
summary email. The value should be a list of strings
1916
representing words to be passed to the command.
1917
1918
+ graphopts (list of strings)
1919
+
1920
+ Analogous to diffopts, but contains options passed to
1921
+ 'git log --graph' when generating the detailed graph for
1922
+ a set of commits (see refchange_showgraph)
1923
+
1924
logopts (list of strings)
1925
1926
Analogous to diffopts, but contains options passed to
1933
commit mail. The value should be a list of strings
1934
representing words to be passed to the command.
1935
1936
+ quiet (bool)
1937
+ On success do not write to stderr
1938
+
1939
+ stdout (bool)
1940
+ Write email to stdout rather than emailing. Useful for debugging
1941
+
1942
+ combine_when_single_commit (bool)
1943
+
1944
+ True if a combined email should be produced when a single
1945
+ new commit is pushed to a branch, False otherwise.
1946
+
1947
"""
1948
1949
REPO_NAME_RE = re.compile(r'^(?P<name>.+?)(?:\.git)$')
1953
self.announce_show_shortlog = False
1954
self.maxcommitemails = 500
1955
self.diffopts = ['--stat', '--summary', '--find-copies-harder']
1956
+ self.graphopts = ['--oneline', '--decorate']
1957
self.logopts = []
1958
+ self.refchange_showgraph = False
1959
self.refchange_showlog = False
1960
self.commitlogopts = ['-C', '--stat', '-p', '--cc']
1961
+ self.quiet = False
1962
+ self.stdout = False
1963
+ self.combine_when_single_commit = True
1964
1965
self.COMPUTED_KEYS = [
1966
'administrator',
1992
def get_pusher_email(self):
1993
return None
1994
1995
+ def get_fromaddr(self):
1996
+ config = Config('user')
1997
+ fromname = config.get('name', default='')
1998
+ fromemail = config.get('email', default='')
1999
+ if fromemail:
2000
+ return formataddr([fromname, fromemail])
2001
+ return self.get_sender()
2002
+
2003
def get_administrator(self):
2004
return 'the administrator of this repository'
2005
2017
return CHARSET
2018
2019
def get_values(self):
1634
- """Return a dictionary {keyword : expansion} for this Environment.
2020
+ """Return a dictionary {keyword: expansion} for this Environment.
2021
2022
This method is called by Change._compute_values(). The keys
2023
in the returned dictionary are available to be used in any of
2085
2086
return lines
2087
2088
+ def log_msg(self, msg):
2089
+ """Write the string msg on a log file or on stderr.
2090
+
2091
+ Sends the text to stderr by default, override to change the behavior."""
2092
+ sys.stderr.write(msg)
2093
+
2094
+ def log_warning(self, msg):
2095
+ """Write the string msg on a log file or on stderr.
2096
+
2097
+ Sends the text to stderr by default, override to change the behavior."""
2098
+ sys.stderr.write(msg)
2099
+
2100
+ def log_error(self, msg):
2101
+ """Write the string msg on a log file or on stderr.
2102
+
2103
+ Sends the text to stderr by default, override to change the behavior."""
2104
+ sys.stderr.write(msg)
2105
+
2106
2107
class ConfigEnvironmentMixin(Environment):
2108
"""A mixin that sets self.config to its constructor's config argument.
2127
config=config, **kw
2128
)
2129
1726
- self.announce_show_shortlog = config.get_bool(
1727
- 'announceshortlog', default=self.announce_show_shortlog
1728
- )
1729
-
1730
- self.refchange_showlog = config.get_bool(
1731
- 'refchangeshowlog', default=self.refchange_showlog
1732
- )
2130
+ for var, cfg in (
2131
+ ('announce_show_shortlog', 'announceshortlog'),
2132
+ ('refchange_showgraph', 'refchangeShowGraph'),
2133
+ ('refchange_showlog', 'refchangeshowlog'),
2134
+ ('quiet', 'quiet'),
2135
+ ('stdout', 'stdout'),
2136
+ ):
2137
+ val = config.get_bool(cfg)
2138
+ if val is not None:
2139
+ setattr(self, var, val)
2140
2141
maxcommitemails = config.get('maxcommitemails')
2142
if maxcommitemails is not None:
2143
try:
2144
self.maxcommitemails = int(maxcommitemails)
2145
except ValueError:
1739
- sys.stderr.write(
2146
+ self.log_warning(
2147
'*** Malformed value for multimailhook.maxCommitEmails: %s\n' % maxcommitemails
2148
+ '*** Expected a number. Ignoring.\n'
2149
)
2152
if diffopts is not None:
2153
self.diffopts = shlex.split(diffopts)
2154
2155
+ graphopts = config.get('graphOpts')
2156
+ if graphopts is not None:
2157
+ self.graphopts = shlex.split(graphopts)
2158
+
2159
logopts = config.get('logopts')
2160
if logopts is not None:
2161
self.logopts = shlex.split(logopts)
2167
reply_to = config.get('replyTo')
2168
self.__reply_to_refchange = config.get('replyToRefchange', default=reply_to)
2169
if (
1759
- self.__reply_to_refchange is not None
1760
- and self.__reply_to_refchange.lower() == 'author'
1761
- ):
2170
+ self.__reply_to_refchange is not None
2171
+ and self.__reply_to_refchange.lower() == 'author'
2172
+ ):
2173
raise ConfigurationException(
2174
'"author" is not an allowed setting for replyToRefchange'
2175
)
2176
self.__reply_to_commit = config.get('replyToCommit', default=reply_to)
2177
2178
+ combine = config.get_bool('combineWhenSingleCommit')
2179
+ if combine is not None:
2180
+ self.combine_when_single_commit = combine
2181
+
2182
def get_administrator(self):
2183
return (
2184
self.config.get('administrator')
2194
2195
def get_emailprefix(self):
2196
emailprefix = self.config.get('emailprefix')
1782
- if emailprefix and emailprefix.strip():
1783
- return emailprefix.strip() + ' '
2197
+ if emailprefix is not None:
2198
+ emailprefix = emailprefix.strip()
2199
+ if emailprefix:
2200
+ return emailprefix + ' '
2201
+ else:
2202
+ return ''
2203
else:
2204
return '[%s] ' % (self.get_repo_shortname(),)
2205
2210
fromaddr = self.config.get('from')
2211
if fromaddr:
2212
return fromaddr
1794
- else:
1795
- config = Config('user')
1796
- fromname = config.get('name', default='')
1797
- fromemail = config.get('email', default='')
1798
- if fromemail:
1799
- return formataddr([fromname, fromemail])
1800
- else:
1801
- return self.get_sender()
2213
+ return super(ConfigOptionsEnvironmentMixin, self).get_fromaddr()
2214
2215
def get_reply_to_refchange(self, refchange):
2216
if self.__reply_to_refchange is None:
2226
if self.__reply_to_commit is None:
2227
return super(ConfigOptionsEnvironmentMixin, self).get_reply_to_commit(revision)
2228
elif self.__reply_to_commit.lower() == 'author':
1817
- return revision.get_author()
2229
+ return revision.author
2230
elif self.__reply_to_commit.lower() == 'pusher':
2231
return self.get_pusher_email()
2232
elif self.__reply_to_commit.lower() == 'none':
2234
else:
2235
return self.__reply_to_commit
2236
2237
+ def get_scancommitforcc(self):
2238
+ return self.config.get('scancommitforcc')
2239
+
2240
2241
class FilterLinesEnvironmentMixin(Environment):
2242
"""Handle encoding and maximum line length of body lines.
2277
2278
2279
class ConfigFilterLinesEnvironmentMixin(
1865
- ConfigEnvironmentMixin,
1866
- FilterLinesEnvironmentMixin,
1867
- ):
2280
+ ConfigEnvironmentMixin,
2281
+ FilterLinesEnvironmentMixin,
2282
+ ):
2283
"""Handle encoding and maximum line length based on config."""
2284
2285
def __init__(self, config, **kw):
2311
2312
2313
class ConfigMaxlinesEnvironmentMixin(
1899
- ConfigEnvironmentMixin,
1900
- MaxlinesEnvironmentMixin,
1901
- ):
2314
+ ConfigEnvironmentMixin,
2315
+ MaxlinesEnvironmentMixin,
2316
+ ):
2317
"""Limit the email body to the number of lines specified in config."""
2318
2319
def __init__(self, config, **kw):
2342
2343
2344
class ConfigFQDNEnvironmentMixin(
1930
- ConfigEnvironmentMixin,
1931
- FQDNEnvironmentMixin,
1932
- ):
2345
+ ConfigEnvironmentMixin,
2346
+ FQDNEnvironmentMixin,
2347
+ ):
2348
"""Read the FQDN from the config."""
2349
2350
def __init__(self, config, **kw):
2385
"""Set recipients statically based on constructor parameters."""
2386
2387
def __init__(
1973
- self,
1974
- refchange_recipients, announce_recipients, revision_recipients,
1975
- **kw
1976
- ):
2388
+ self,
2389
+ refchange_recipients, announce_recipients, revision_recipients, scancommitforcc,
2390
+ **kw
2391
+ ):
2392
super(StaticRecipientsEnvironmentMixin, self).__init__(**kw)
2393
2394
# The recipients for various types of notification emails, as
2400
# compute them once and for all:
2401
if not (refchange_recipients
2402
or announce_recipients
1988
- or revision_recipients):
2403
+ or revision_recipients
2404
+ or scancommitforcc):
2405
raise ConfigurationException('No email recipients configured!')
2406
self.__refchange_recipients = refchange_recipients
2407
self.__announce_recipients = announce_recipients
2418
2419
2420
class ConfigRecipientsEnvironmentMixin(
2005
- ConfigEnvironmentMixin,
2006
- StaticRecipientsEnvironmentMixin
2007
- ):
2421
+ ConfigEnvironmentMixin,
2422
+ StaticRecipientsEnvironmentMixin
2423
+ ):
2424
"""Determine recipients statically based on config."""
2425
2426
def __init__(self, config, **kw):
2435
revision_recipients=self._get_recipients(
2436
config, 'commitlist', 'mailinglist',
2437
),
2438
+ scancommitforcc=config.get('scancommitforcc'),
2439
**kw
2440
)
2441
2484
2485
class GenericEnvironmentMixin(Environment):
2486
def get_pusher(self):
2070
- return self.osenv.get('USER', 'unknown user')
2487
+ return self.osenv.get('USER', self.osenv.get('USERNAME', 'unknown user'))
2488
2489
2490
class GenericEnvironment(
2074
- ProjectdescEnvironmentMixin,
2075
- ConfigMaxlinesEnvironmentMixin,
2076
- ComputeFQDNEnvironmentMixin,
2077
- ConfigFilterLinesEnvironmentMixin,
2078
- ConfigRecipientsEnvironmentMixin,
2079
- PusherDomainEnvironmentMixin,
2080
- ConfigOptionsEnvironmentMixin,
2081
- GenericEnvironmentMixin,
2082
- Environment,
2083
- ):
2491
+ ProjectdescEnvironmentMixin,
2492
+ ConfigMaxlinesEnvironmentMixin,
2493
+ ComputeFQDNEnvironmentMixin,
2494
+ ConfigFilterLinesEnvironmentMixin,
2495
+ ConfigRecipientsEnvironmentMixin,
2496
+ PusherDomainEnvironmentMixin,
2497
+ ConfigOptionsEnvironmentMixin,
2498
+ GenericEnvironmentMixin,
2499
+ Environment,
2500
+ ):
2501
pass
2502
2503
2514
def get_pusher(self):
2515
return self.osenv.get('GL_USER', 'unknown user')
2516
2517
+ def get_fromaddr(self):
2518
+ GL_USER = self.osenv.get('GL_USER')
2519
+ if GL_USER is not None:
2520
+ # Find the path to gitolite.conf. Note that gitolite v3
2521
+ # did away with the GL_ADMINDIR and GL_CONF environment
2522
+ # variables (they are now hard-coded).
2523
+ GL_ADMINDIR = self.osenv.get(
2524
+ 'GL_ADMINDIR',
2525
+ os.path.expanduser(os.path.join('~', '.gitolite')))
2526
+ GL_CONF = self.osenv.get(
2527
+ 'GL_CONF',
2528
+ os.path.join(GL_ADMINDIR, 'conf', 'gitolite.conf'))
2529
+ if os.path.isfile(GL_CONF):
2530
+ f = open(GL_CONF, 'rU')
2531
+ try:
2532
+ in_user_emails_section = False
2533
+ re_template = r'^\s*#\s*{}\s*$'
2534
+ re_begin, re_user, re_end = (
2535
+ re.compile(re_template.format(x))
2536
+ for x in (
2537
+ r'BEGIN\s+USER\s+EMAILS',
2538
+ re.escape(GL_USER) + r'\s+(.*)',
2539
+ r'END\s+USER\s+EMAILS',
2540
+ ))
2541
+ for l in f:
2542
+ l = l.rstrip('\n')
2543
+ if not in_user_emails_section:
2544
+ if re_begin.match(l):
2545
+ in_user_emails_section = True
2546
+ continue
2547
+ if re_end.match(l):
2548
+ break
2549
+ m = re_user.match(l)
2550
+ if m:
2551
+ return m.group(1)
2552
+ finally:
2553
+ f.close()
2554
+ return super(GitoliteEnvironmentMixin, self).get_fromaddr()
2555
+
2556
2557
class IncrementalDateTime(object):
2558
"""Simple wrapper to give incremental date/times.
2572
2573
2574
class GitoliteEnvironment(
2119
- ProjectdescEnvironmentMixin,
2120
- ConfigMaxlinesEnvironmentMixin,
2121
- ComputeFQDNEnvironmentMixin,
2122
- ConfigFilterLinesEnvironmentMixin,
2123
- ConfigRecipientsEnvironmentMixin,
2124
- PusherDomainEnvironmentMixin,
2125
- ConfigOptionsEnvironmentMixin,
2126
- GitoliteEnvironmentMixin,
2127
- Environment,
2128
- ):
2575
+ ProjectdescEnvironmentMixin,
2576
+ ConfigMaxlinesEnvironmentMixin,
2577
+ ComputeFQDNEnvironmentMixin,
2578
+ ConfigFilterLinesEnvironmentMixin,
2579
+ ConfigRecipientsEnvironmentMixin,
2580
+ PusherDomainEnvironmentMixin,
2581
+ ConfigOptionsEnvironmentMixin,
2582
+ GitoliteEnvironmentMixin,
2583
+ Environment,
2584
+ ):
2585
pass
2586
2587
2605
references.
2606
2607
The first step is to determine the "other" references--those
2152
- unaffected by the current push. They are computed by
2153
- Push._compute_other_ref_sha1s() by listing all references then
2154
- removing any affected by this push.
2608
+ unaffected by the current push. They are computed by listing all
2609
+ references then removing any affected by this push. The results
2610
+ are stored in Push._other_ref_sha1s.
2611
2612
The commits contained in the repository before this push were
2613
2643
possible and working with SHA1s thereafter (because SHA1s are
2644
immutable)."""
2645
2190
- # A map {(changeclass, changetype) : integer} specifying the order
2646
+ # A map {(changeclass, changetype): integer} specifying the order
2647
# that reference changes will be processed if multiple reference
2648
# changes are included in a single push. The order is significant
2649
# mostly because new commit notifications are threaded together
2667
])
2668
)
2669
2214
- def __init__(self, changes):
2670
+ def __init__(self, changes, ignore_other_refs=False):
2671
self.changes = sorted(changes, key=self._sort_key)
2672
+ self.__other_ref_sha1s = None
2673
+ self.__cached_commits_spec = {}
2674
2217
- # The SHA-1s of commits referred to by references unaffected
2218
- # by this push:
2219
- other_ref_sha1s = self._compute_other_ref_sha1s()
2675
+ if ignore_other_refs:
2676
+ self.__other_ref_sha1s = set()
2677
2221
- self._old_rev_exclusion_spec = self._compute_rev_exclusion_spec(
2222
- other_ref_sha1s.union(
2223
- change.old.sha1
2678
+ @classmethod
2679
+ def _sort_key(klass, change):
2680
+ return (klass.SORT_ORDER[change.__class__, change.change_type], change.refname,)
2681
+
2682
+ @property
2683
+ def _other_ref_sha1s(self):
2684
+ """The GitObjects referred to by references unaffected by this push.
2685
+ """
2686
+ if self.__other_ref_sha1s is None:
2687
+ # The refnames being changed by this push:
2688
+ updated_refs = set(
2689
+ change.refname
2690
for change in self.changes
2225
- if change.old.type in ['commit', 'tag']
2691
)
2227
- )
2228
- self._new_rev_exclusion_spec = self._compute_rev_exclusion_spec(
2229
- other_ref_sha1s.union(
2230
- change.new.sha1
2231
- for change in self.changes
2232
- if change.new.type in ['commit', 'tag']
2692
+
2693
+ # The SHA-1s of commits referred to by all references in this
2694
+ # repository *except* updated_refs:
2695
+ sha1s = set()
2696
+ fmt = (
2697
+ '%(objectname) %(objecttype) %(refname)\n'
2698
+ '%(*objectname) %(*objecttype) %(refname)'
2699
)
2234
- )
2700
+ for line in read_git_lines(
2701
+ ['for-each-ref', '--format=%s' % (fmt,)]):
2702
+ (sha1, type, name) = line.split(' ', 2)
2703
+ if sha1 and type == 'commit' and name not in updated_refs:
2704
+ sha1s.add(sha1)
2705
2236
- @classmethod
2237
- def _sort_key(klass, change):
2238
- return (klass.SORT_ORDER[change.__class__, change.change_type], change.refname,)
2706
+ self.__other_ref_sha1s = sha1s
2707
+
2708
+ return self.__other_ref_sha1s
2709
+
2710
+ def _get_commits_spec_incl(self, new_or_old, reference_change=None):
2711
+ """Get new or old SHA-1 from one or each of the changed refs.
2712
2240
- def _compute_other_ref_sha1s(self):
2241
- """Return the GitObjects referred to by references unaffected by this push."""
2713
+ Return a list of SHA-1 commit identifier strings suitable as
2714
+ arguments to 'git rev-list' (or 'git log' or ...). The
2715
+ returned identifiers are either the old or new values from one
2716
+ or all of the changed references, depending on the values of
2717
+ new_or_old and reference_change.
2718
2243
- # The refnames being changed by this push:
2244
- updated_refs = set(
2245
- change.refname
2719
+ new_or_old is either the string 'new' or the string 'old'. If
2720
+ 'new', the returned SHA-1 identifiers are the new values from
2721
+ each changed reference. If 'old', the SHA-1 identifiers are
2722
+ the old values from each changed reference.
2723
+
2724
+ If reference_change is specified and not None, only the new or
2725
+ old reference from the specified reference is included in the
2726
+ return value.
2727
+
2728
+ This function returns None if there are no matching revisions
2729
+ (e.g., because a branch was deleted and new_or_old is 'new').
2730
+ """
2731
+
2732
+ if not reference_change:
2733
+ incl_spec = sorted(
2734
+ getattr(change, new_or_old).sha1
2735
+ for change in self.changes
2736
+ if getattr(change, new_or_old)
2737
+ )
2738
+ if not incl_spec:
2739
+ incl_spec = None
2740
+ elif not getattr(reference_change, new_or_old).commit_sha1:
2741
+ incl_spec = None
2742
+ else:
2743
+ incl_spec = [getattr(reference_change, new_or_old).commit_sha1]
2744
+ return incl_spec
2745
+
2746
+ def _get_commits_spec_excl(self, new_or_old):
2747
+ """Get exclusion revisions for determining new or discarded commits.
2748
+
2749
+ Return a list of strings suitable as arguments to 'git
2750
+ rev-list' (or 'git log' or ...) that will exclude all
2751
+ commits that, depending on the value of new_or_old, were
2752
+ either previously in the repository (useful for determining
2753
+ which commits are new to the repository) or currently in the
2754
+ repository (useful for determining which commits were
2755
+ discarded from the repository).
2756
+
2757
+ new_or_old is either the string 'new' or the string 'old'. If
2758
+ 'new', the commits to be excluded are those that were in the
2759
+ repository before the push. If 'old', the commits to be
2760
+ excluded are those that are currently in the repository. """
2761
+
2762
+ old_or_new = {'old': 'new', 'new': 'old'}[new_or_old]
2763
+ excl_revs = self._other_ref_sha1s.union(
2764
+ getattr(change, old_or_new).sha1
2765
for change in self.changes
2766
+ if getattr(change, old_or_new).type in ['commit', 'tag']
2767
)
2768
+ return ['^' + sha1 for sha1 in sorted(excl_revs)]
2769
2249
- # The SHA-1s of commits referred to by all references in this
2250
- # repository *except* updated_refs:
2251
- sha1s = set()
2252
- fmt = (
2253
- '%(objectname) %(objecttype) %(refname)\n'
2254
- '%(*objectname) %(*objecttype) %(refname)'
2255
- )
2256
- for line in read_git_lines(['for-each-ref', '--format=%s' % (fmt,)]):
2257
- (sha1, type, name) = line.split(' ', 2)
2258
- if sha1 and type == 'commit' and name not in updated_refs:
2259
- sha1s.add(sha1)
2770
+ def get_commits_spec(self, new_or_old, reference_change=None):
2771
+ """Get rev-list arguments for added or discarded commits.
2772
2261
- return sha1s
2773
+ Return a list of strings suitable as arguments to 'git
2774
+ rev-list' (or 'git log' or ...) that select those commits
2775
+ that, depending on the value of new_or_old, are either new to
2776
+ the repository or were discarded from the repository.
2777
2263
- def _compute_rev_exclusion_spec(self, sha1s):
2264
- """Return an exclusion specification for 'git rev-list'.
2778
+ new_or_old is either the string 'new' or the string 'old'. If
2779
+ 'new', the returned list is used to select commits that are
2780
+ new to the repository. If 'old', the returned value is used
2781
+ to select the commits that have been discarded from the
2782
+ repository.
2783
2266
- git_objects is an iterable over GitObject instances. Return a
2267
- string that can be passed to the standard input of 'git
2268
- rev-list --stdin' to exclude all of the commits referred to by
2269
- git_objects."""
2784
+ If reference_change is specified and not None, the new or
2785
+ discarded commits are limited to those that are reachable from
2786
+ the new or old value of the specified reference.
2787
2271
- return ''.join(
2272
- ['^%s\n' % (sha1,) for sha1 in sorted(sha1s)]
2273
- )
2788
+ This function returns None if there are no added (or discarded)
2789
+ revisions.
2790
+ """
2791
+ key = (new_or_old, reference_change)
2792
+ if key not in self.__cached_commits_spec:
2793
+ ret = self._get_commits_spec_incl(new_or_old, reference_change)
2794
+ if ret is not None:
2795
+ ret.extend(self._get_commits_spec_excl(new_or_old))
2796
+ self.__cached_commits_spec[key] = ret
2797
+ return self.__cached_commits_spec[key]
2798
2799
def get_new_commits(self, reference_change=None):
2800
"""Return a list of commits added by this push.
2804
reference_change is None, then return a list of *all* commits
2805
added by this push."""
2806
2283
- if not reference_change:
2284
- new_revs = sorted(
2285
- change.new.sha1
2286
- for change in self.changes
2287
- if change.new
2288
- )
2289
- elif not reference_change.new.commit_sha1:
2290
- return []
2291
- else:
2292
- new_revs = [reference_change.new.commit_sha1]
2293
-
2294
- cmd = ['rev-list', '--stdin'] + new_revs
2295
- return read_git_lines(cmd, input=self._old_rev_exclusion_spec)
2807
+ spec = self.get_commits_spec('new', reference_change)
2808
+ return git_rev_list(spec)
2809
2810
def get_discarded_commits(self, reference_change):
2811
"""Return a list of commits discarded by this push.
2814
entirely discarded from the repository by the part of this
2815
push represented by reference_change."""
2816
2304
- if not reference_change.old.commit_sha1:
2305
- return []
2306
- else:
2307
- old_revs = [reference_change.old.commit_sha1]
2308
-
2309
- cmd = ['rev-list', '--stdin'] + old_revs
2310
- return read_git_lines(cmd, input=self._new_rev_exclusion_spec)
2817
+ spec = self.get_commits_spec('old', reference_change)
2818
+ return git_rev_list(spec)
2819
2820
def send_emails(self, mailer, body_filter=None):
2821
"""Use send all of the notification emails needed for this push.
2833
unhandled_sha1s = set(self.get_new_commits())
2834
send_date = IncrementalDateTime()
2835
for change in self.changes:
2836
+ sha1s = []
2837
+ for sha1 in reversed(list(self.get_new_commits(change))):
2838
+ if sha1 in unhandled_sha1s:
2839
+ sha1s.append(sha1)
2840
+ unhandled_sha1s.remove(sha1)
2841
+
2842
# Check if we've got anyone to send to
2843
if not change.recipients:
2330
- sys.stderr.write(
2844
+ change.environment.log_warning(
2845
'*** no recipients configured so no email will be sent\n'
2846
'*** for %r update %s->%s\n'
2847
% (change.refname, change.old.sha1, change.new.sha1,)
2848
)
2849
else:
2336
- sys.stderr.write('Sending notification emails to: %s\n' % (change.recipients,))
2337
- extra_values = {'send_date' : send_date.next()}
2338
- mailer.send(
2339
- change.generate_email(self, body_filter, extra_values),
2340
- change.recipients,
2341
- )
2850
+ if not change.environment.quiet:
2851
+ change.environment.log_msg(
2852
+ 'Sending notification emails to: %s\n' % (change.recipients,))
2853
+ extra_values = {'send_date': send_date.next()}
2854
2343
- sha1s = []
2344
- for sha1 in reversed(list(self.get_new_commits(change))):
2345
- if sha1 in unhandled_sha1s:
2346
- sha1s.append(sha1)
2347
- unhandled_sha1s.remove(sha1)
2855
+ rev = change.send_single_combined_email(sha1s)
2856
+ if rev:
2857
+ mailer.send(
2858
+ change.generate_combined_email(self, rev, body_filter, extra_values),
2859
+ rev.recipients,
2860
+ )
2861
+ # This change is now fully handled; no need to handle
2862
+ # individual revisions any further.
2863
+ continue
2864
+ else:
2865
+ mailer.send(
2866
+ change.generate_email(self, body_filter, extra_values),
2867
+ change.recipients,
2868
+ )
2869
2870
max_emails = change.environment.maxcommitemails
2871
if max_emails and len(sha1s) > max_emails:
2351
- sys.stderr.write(
2872
+ change.environment.log_warning(
2873
'*** Too many new commits (%d), not sending commit emails.\n' % len(sha1s)
2874
+ '*** Try setting multimailhook.maxCommitEmails to a greater value\n'
2875
+ '*** Currently, multimailhook.maxCommitEmails=%d\n' % max_emails
2877
return
2878
2879
for (num, sha1) in enumerate(sha1s):
2359
- rev = Revision(change, GitObject(sha1), num=num+1, tot=len(sha1s))
2880
+ rev = Revision(change, GitObject(sha1), num=num + 1, tot=len(sha1s))
2881
+ if not rev.recipients and rev.cc_recipients:
2882
+ change.environment.log_msg('*** Replacing Cc: with To:\n')
2883
+ rev.recipients = rev.cc_recipients
2884
+ rev.cc_recipients = None
2885
if rev.recipients:
2361
- extra_values = {'send_date' : send_date.next()}
2886
+ extra_values = {'send_date': send_date.next()}
2887
mailer.send(
2888
rev.generate_email(self, body_filter, extra_values),
2889
rev.recipients,
2891
2892
# Consistency check:
2893
if unhandled_sha1s:
2369
- sys.stderr.write(
2894
+ change.environment.log_error(
2895
'ERROR: No emails were sent for the following new commits:\n'
2896
' %s\n'
2897
% ('\n '.join(sorted(unhandled_sha1s)),)
2909
push.send_emails(mailer, body_filter=environment.filter_body)
2910
2911
2387
-def run_as_update_hook(environment, mailer, refname, oldrev, newrev):
2912
+def run_as_update_hook(environment, mailer, refname, oldrev, newrev, force_send=False):
2913
changes = [
2914
ReferenceChange.create(
2915
environment,
2918
refname,
2919
),
2920
]
2396
- push = Push(changes)
2921
+ push = Push(changes, force_send)
2922
push.send_emails(mailer, body_filter=environment.filter_body)
2923
2924
2927
2928
if mailer == 'smtp':
2929
smtpserver = config.get('smtpserver', default='localhost')
2930
+ smtpservertimeout = float(config.get('smtpservertimeout', default=10.0))
2931
+ smtpserverdebuglevel = int(config.get('smtpserverdebuglevel', default=0))
2932
+ smtpencryption = config.get('smtpencryption', default='none')
2933
+ smtpuser = config.get('smtpuser', default='')
2934
+ smtppass = config.get('smtppass', default='')
2935
mailer = SMTPMailer(
2936
envelopesender=(environment.get_sender() or environment.get_fromaddr()),
2407
- smtpserver=smtpserver,
2937
+ smtpserver=smtpserver, smtpservertimeout=smtpservertimeout,
2938
+ smtpserverdebuglevel=smtpserverdebuglevel,
2939
+ smtpencryption=smtpencryption,
2940
+ smtpuser=smtpuser,
2941
+ smtppass=smtppass,
2942
)
2943
elif mailer == 'sendmail':
2944
command = config.get('sendmailcommand')
2946
command = shlex.split(command)
2947
mailer = SendMailer(command=command, envelopesender=environment.get_sender())
2948
else:
2415
- sys.stderr.write(
2949
+ environment.log_error(
2950
'fatal: multimailhook.mailer is set to an incorrect value: "%s"\n' % mailer
2951
+ 'please use one of "smtp" or "sendmail".\n'
2952
)
2955
2956
2957
KNOWN_ENVIRONMENTS = {
2424
- 'generic' : GenericEnvironmentMixin,
2425
- 'gitolite' : GitoliteEnvironmentMixin,
2958
+ 'generic': GenericEnvironmentMixin,
2959
+ 'gitolite': GitoliteEnvironmentMixin,
2960
}
2961
2962
2973
ConfigOptionsEnvironmentMixin,
2974
]
2975
environment_kw = {
2442
- 'osenv' : osenv,
2443
- 'config' : config,
2976
+ 'osenv': osenv,
2977
+ 'config': config,
2978
}
2979
2980
if not env:
2993
environment_kw['refchange_recipients'] = recipients
2994
environment_kw['announce_recipients'] = recipients
2995
environment_kw['revision_recipients'] = recipients
2996
+ environment_kw['scancommitforcc'] = config.get('scancommitforcc')
2997
else:
2998
environment_mixins.insert(0, ConfigRecipientsEnvironmentMixin)
2999
3034
'(intended for debugging purposes).'
3035
),
3036
)
3037
+ parser.add_option(
3038
+ '--force-send', action='store_true', default=False,
3039
+ help=(
3040
+ 'Force sending refchange email when using as an update hook. '
3041
+ 'This is useful to work around the unreliable new commits '
3042
+ 'detection in this mode.'
3043
+ ),
3044
+ )
3045
3046
(options, args) = parser.parse_args(args)
3047
3056
3057
if options.show_env:
3058
sys.stderr.write('Environment values:\n')
2516
- for (k,v) in sorted(environment.get_values().items()):
2517
- sys.stderr.write(' %s : %r\n' % (k,v))
3059
+ for (k, v) in sorted(environment.get_values().items()):
3060
+ sys.stderr.write(' %s : %r\n' % (k, v))
3061
sys.stderr.write('\n')
3062
2520
- if options.stdout:
3063
+ if options.stdout or environment.stdout:
3064
mailer = OutputMailer(sys.stdout)
3065
else:
3066
mailer = choose_mailer(config, environment)
3071
if len(args) != 3:
3072
parser.error('Need zero or three non-option arguments')
3073
(refname, oldrev, newrev) = args
2531
- run_as_update_hook(environment, mailer, refname, oldrev, newrev)
3074
+ run_as_update_hook(environment, mailer, refname, oldrev, newrev, options.force_send)
3075
else:
3076
run_as_post_receive_hook(environment, mailer)
3077
except ConfigurationException, e: