-
Notifications
You must be signed in to change notification settings - Fork 13
/
__init__.py
1207 lines (1054 loc) · 49.9 KB
/
__init__.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""The updates root module."""
from config import (
git_config,
SUBJECT_MAX_SUBJECT_CHARS,
ThirdPartyHook,
CONFIG_FILENAME,
CONFIG_REF,
)
from copy import deepcopy
from enum import Enum
from errors import InvalidUpdate
import json
from git import git, is_null_rev, commit_parents, commit_rev, split_ref_name
from pre_commit_checks import (
check_revision_history,
style_check_commit,
check_filename_collisions,
check_filepath_length,
reject_commit_if_merge,
)
import re
import shlex
import sys
from io_utils import safe_decode_by_line
from updates.commits import commit_info_list
from updates.emails import EmailCustomContents, EmailInfo, Email
from updates.mailinglists import expanded_mailing_list
from utils import commit_email_subject_prefix
from utils import debug, warn, indent, search_config_option_list
# The different kinds of references we handle.
#
# Note: We try to list the entries in order of frequency, with more
# frequent updates first. That way, anytime some code iterates over
# this enum, it'll get the more frequent update first.
class RefKind(Enum):
# A branch. The vast majority of updates will be branch updates.
branch_ref = "branch"
# A special branch used to hold git notes.
notes_ref = "notes"
# A tag reference (either annotated or lightweight).
tag_ref = "tag"
# The different types of reference updates.
class UpdateKind(Enum):
# A new reference being created;
create = 1
# An existing reference being deleted;
delete = 2
# An existing reference being updated (it already existed before,
# and its value is being changed).
update = 3
class AbstractUpdate(object):
"""An abstract class representing a reference update.
ATTRIBUTES
ref_name: The name of the reference being updated.
short_ref_name: The reference's short name. It is obtained by
removing the "refs/[...]/" prefix. For example, if ref_name
is "refs/heads/master", then short_ref_name is "master".
ref_namespace: The part of the reference name prior to
the short_ref_name (excluding the separating '/').
It can be None if the namespace could not be parsed out of
the ref_name, in which case short_ref_name should be equal
to ref_name.
ref_kind: The kind of reference being updated (a RefKind object).
object_type: A string, indicating the type of object pointed at
by the reference being updated (using self.new_rev unless
the reference is being deleted, in which case we use
self.old_rev instead). See "git cat-file -t" for more
information.
old_rev: The reference's revision (SHA1) prior to the update.
A null revision means that this is a new reference.
new_rev: The reference's revision (SHA1) after the update.
A null revision means that this reference is being deleted.
all_refs: A dictionary containing all references, as described
in git_show_ref.
email_info: An EmailInfo object. See REMARKS below.
new_commits_for_ref: A list of CommitInfo objects which are new
to the reference being updated.
commits_to_check: A list of CommitInfo that should be checked
prior to accept the update.
lost_commits: A list of CommitInfo objects lost after our update.
REMARKS
This class is meant to be abstract and should never be instantiated.
Regarding the "email_info" attribute: This object is not strictly
required during the "update" phase. However, instantiating it
allows for two things:
1. The instantiation itself verifies that the repository
provides the minimum configuration required to send
email notifications, and raise an InvalidUpdate exception
if not.
2. It contains some information which is used repeatedly
(Eg: the module name can be queries every time a file
is style-checked). We will use this object to provide
this required information, rather than recomputing it
repeatedly.
"""
def __init__(
self,
ref_name,
ref_kind,
object_type,
old_rev,
new_rev,
all_refs,
submitter_email,
):
"""The constructor.
Also calls self.auto_sanity_check() at the end.
PARAMETERS
ref_name: Same as the attribute.
ref_kind: Same as the attribute.
object_type: Same as the attribute.
old_rev: Same as the attribute.
new_rev: Same as the attribute.
all_refs: Same as the attribute.
submitter_email: Same as parameter from_email in class
EmailInfo's constructor.
This is used to override the default "from" email when
the user sending the emails is different from the user
that pushed/submitted the update.
"""
# If the repository's configuration does not provide
# the minimum required to email update notifications,
# refuse the update.
if not git_config("hooks.mailinglist"):
raise InvalidUpdate(
"Error: hooks.mailinglist config option not set.",
"Please contact your repository's administrator.",
)
self.ref_name = ref_name
self.ref_namespace, self.short_ref_name = split_ref_name(ref_name)
self.ref_kind = ref_kind
self.object_type = object_type
self.old_rev = old_rev
self.new_rev = new_rev
self.all_refs = all_refs
self.email_info = EmailInfo(email_from=submitter_email)
# Implement the new_commits_for_ref "attribute" as a property,
# to allow for initialization only on-demand. This allows
# us to avoid computing this list until the moment we
# actually need it. To help caching its value, avoiding
# the need to compute it multiple times, we introduce
# a private attribute named __new_commits_for_ref.
#
# Same treatment for the following other "attribute":
# - commits_to_check
# - lost_commits
self.__new_commits_for_ref = None
self.__commits_to_check = None
self.__lost_commits = None
self.self_sanity_check()
def validate(self):
"""Raise InvalidUpdate if the update is invalid.
This method verifies that the reference update itself is valid
(by calling the validate_ref_update method), and then verifies
that all the new commits introduced by this update passes
style-checking. Otherwise, raise InvalidUpdate.
"""
debug(
"validate_ref_update (%s, %s, %s)"
% (self.ref_name, self.old_rev, self.new_rev)
)
self.__reject_frozen_ref_update()
self.validate_ref_update()
self.__check_max_commit_emails()
self.pre_commit_checks()
def send_email_notifications(self):
"""Send all email notifications associated to this update."""
no_email_re = self.search_config_option_list("hooks.no-emails")
if no_email_re is not None:
# Python-2.x compatibility: When the list of arguments in a call
# are so long that they get formatted over multiple lines, black
# adds a comma at the end of the last argument. Unfortunately,
# it looks like Python 2.x doesn't like that comma when one of
# the parameter is a non-keyworded variable-length argument list
# (aka *args).
#
# So, work around this issue until the transition to Python 3.x
# by building a list with all the unnamed arguments, and then
# use that list to make the call, thus ensuring that the call
# is short-enough to fit in a single line.
warn_msg = [
"-" * 70,
"-- The hooks.no-emails config option contains `%s'," % no_email_re,
"-- which matches the name of the reference being" " updated ",
"-- (%s)." % self.ref_name,
"--",
"-- Commit emails will therefore not be sent.",
"-" * 70,
]
warn(*warn_msg, prefix="")
return
# This phase needs all added commits to have certain attributes
# to be computed. Do it now.
self.__set_send_email_p_attr(self.new_commits_for_ref)
self.__email_ref_update()
self.__email_new_commits()
# ------------------------------------------------------------------
# -- Abstract methods that must be overridden by child classes. --
# ------------------------------------------------------------------
def self_sanity_check(self):
"""raise an assertion failure if the init parameters are invalid...
This method should check that the valud of the attributes created
at initialization time correspond to values expected for the
class. For instance, a class that handles branch creation only
should verify that the ref_name starts with 'refs/heads/' and
that old_rev is null.
REMARKS
This method is abstract and should be overridden.
"""
raise AssertionError("Abstract method must be overridden")
def validate_ref_update(self):
"""Raise InvalidUpdate if the update is invalid.
REMARKS
This method is abstract and should be overridden.
"""
raise AssertionError("Abstract method must be overridden")
def get_update_email_contents(self):
"""Return a tuple describing the update (or None).
This method should return a 3-element tuple to be used for
creating an email describing the reference update, containing
the following elements (in that order):
- the email distribution list (an iterable);
- the email subject (a string);
- the email body (a string).
The email is meant to describe the type of update that was
made. For instance, for an annotated tag creation, the email
should describe the name of the tag, the commit it points to,
and perhaps the revision history associated to this tag. For
a branch update, the email should provide the name of the branch,
the new commit it points to, and possibly a list of commits that
are introduced by this update.
This email should NOT, however, be confused with the emails
that will be sent for each new commit introduced by this
update. Those emails are going to be taken care of separatly.
Return None if a "cover" email is not going to be useful.
For instance, if a branch update only introduces a few new
commits, a branch update summary email is not going to bring
much, and thus can be omitted.
"""
raise AssertionError("Abstract method must be overridden")
# ------------------------------------------------------------------
# -- Methods that child classes may override. --
# ------------------------------------------------------------------
def pre_commit_checks(self):
"""Run the pre-commit checks on this update's new commits.
Determine the list of new commits introduced by this
update, and perform the pre-commit-checks on them as
appropriate. Raise InvalidUpdate if one or more style
violation was detected.
"""
if is_null_rev(self.new_rev):
# We are deleting a reference, so there cannot be any
# new commit.
return
# Check to see if any of the entries in hooks.no-precommit-check
# might be matching our reference name...
for exp in git_config("hooks.no-precommit-check"):
exp = exp.strip()
if re.match(exp, self.ref_name):
# Pre-commit checks are explicitly disabled on this branch.
debug("(hooks.no-precommit-check match: `%s')" % exp)
return
# Perform the check against merge commits early, for a couple
# of reasons: (1) this is a relatively inexpensive check to be
# performing, so might as well do it now and error out early
# if the update is violating this check; and (2) we want to
# perform this check on all commits new for this reference,
# including any commit that we would otherwise exclude for
# the other validation checks (such as revert commits, for instance).
# Otherwise, we cannot guaranty that a given reference which
# is configured to disallow merge commits stays free of merge
# commits.
reject_merge_commits = (
self.search_config_option_list("hooks.reject-merge-commits") is not None
)
if reject_merge_commits:
# See comment just above explaining why, for this check,
# we iterate over self.new_commits_for_ref rather than
# self.commits_to_check.
for commit in self.new_commits_for_ref:
reject_commit_if_merge(commit, self.ref_name)
# Determine whether we should be doing RH style checking...
do_rh_style_checks = (
self.search_config_option_list("hooks.no-rh-style-checks") is None
)
# Perform the revision-history of all new commits, unless
# specifically disabled by configuration.
#
# Done separately from the rest of the pre-commit checks,
# which check the files changed by the commits, because of
# the case where hooks.combined-style-checking is true;
# we do not want to forget checking the revision history
# of some of the commits.
if do_rh_style_checks:
for commit in self.commits_to_check:
check_revision_history(commit)
# Perform the filename-collision checks. These collisions
# can cause a lot of confusion and fustration to the users,
# so do not provide the option of doing the check on the
# final commit only (following hooks.combined-style-checking).
# Do it on all new commits.
for commit in self.commits_to_check:
check_filename_collisions(commit)
# Perform the filepath length checks. File paths which
# are too long can cause trouble on some file systems,
# so check every single commit to avoid introducing
# any commits which would violate this check.
for commit in self.commits_to_check:
check_filepath_length(commit)
self.call_project_specific_commit_checker()
self.__do_style_checks()
def get_standard_commit_email(self, commit):
"""Return an Email object for the given commit.
Here, "standard" means that the Email returned corresponds
to the Email the git-hooks sends by default, before any
possible project-specific customization is applied.
Before sending this email, users of this method are expected
to apply those customizations as needed.
PARAMETERS
commit: A CommitInfo object.
"""
subject_prefix = commit_email_subject_prefix(
self.email_info.project_name,
self.ref_name,
)
subject = f"{subject_prefix} {commit.subject[:SUBJECT_MAX_SUBJECT_CHARS]}"
# Generate the body of the email in two pieces:
# 1. The commit description without the patch;
# 2. The diff stat and patch.
# This allows us to insert our little "Diff:" marker that
# bugtool detects when parsing the email for filing (this
# part is now performed by the Email class). The purpose
# is to prevent bugtool from searching for TNs in the patch
# itself.
body = git.log(commit.rev, max_count="1", _decode=True) + "\n"
if git_config("hooks.commit-url") is not None:
url_info = {"rev": commit.rev, "ref_name": self.ref_name}
body = git_config("hooks.commit-url") % url_info + "\n\n" + body
if git_config("hooks.disable-email-diff"):
diff = None
else:
diff = git.show(commit.rev, p=True, M=True, stat=True, pretty="format:")
# Decode the diff line-by-line:
#
# It seems conceivable that the diff may cover multiple files,
# and that the files may have different encodings, so we cannot
# assume that the entire output follows the same encoding.
diff = safe_decode_by_line(diff)
# Add a small "---" separator line at the beginning of
# the diff section we just computed. This mimicks what
# "git show" would do if we hadn't provided an empty
# "format:" string to the "--pretty" command-line option.
diff = "---\n" + diff
filer_cmd = git_config("hooks.file-commit-cmd")
if filer_cmd is not None:
filer_cmd = shlex.split(filer_cmd)
email_bcc = git_config("hooks.filer-email")
return Email(
self.email_info,
commit.email_to(self.ref_name),
email_bcc,
subject,
body,
commit.full_author_email,
self.ref_name,
commit.base_rev_for_display(),
commit.rev,
diff,
filer_cmd=filer_cmd,
)
# -----------------------
# -- Useful methods. --
# -----------------------
def human_readable_ref_name(self, action=None, default_namespace="refs/heads"):
"""Return a human-readable image (string) of our reference.
This function returns a string describing self.ref_name
in a way that's easier for users to grasp:
"'" {short_name} "'" [ action ] [ "in namespace '" {namespace} "'" ]
PARAMETERS
action: If not None, the "action" part of the returned image.
Otherwise, the "action" section of the return image
is simply skipped.
default_namespace: The namespace that is used by default
for the kind of reference that self.ref_name is.
We use this to determine whether or not the returned
image needs to specify the namespace of our reference,
or whether it can be omitted.
"""
if action is None:
action = ""
else:
action = " {}".format(action)
# For references that are ourside the given default_namespace,
# make the namespace explicit.
if self.ref_namespace in (None, default_namespace):
loc = ""
else:
loc = " in namespace '{}'".format(self.ref_namespace)
return "'{short_ref_name}'{action}{loc}".format(
short_ref_name=self.short_ref_name, action=action, loc=loc
)
def search_config_option_list(self, option_name, ref_name=None):
"""See utils.search_config_option_list
This is a convenient wrapper around utils.search_config_option_list.
PARAMETERS
option_name: Same as in utils.search_config_option_list.
ref_name: Same as in utils.search_config_option_list.
If None, use self.ref_name.
RETURN VALUE
The first regular expression matching REF_NAME, or None.
"""
if ref_name is None:
ref_name = self.ref_name
return search_config_option_list(option_name, ref_name)
def get_refs_matching_config(self, config_name):
"""Return the list of references matching the given config option."""
result = []
for ref_name in self.all_refs.keys():
if self.search_config_option_list(config_name, ref_name) is not None:
result.append(ref_name)
return result
def summary_of_changes(self):
"""A summary of changes to be added at the end of the ref-update email.
Note that we used to display the list of commits twice:
First in a list of one-line representations, as an overview;
and then a second time, displaying the same list where each
commit is printed in full (minus the diff itself).
We found that the second list made the emails much too large,
for information that wasn't considered useful in general,
and easily reconstructed on demand if needed. So we dropped
the second list entirely.
We could have kept a shorter version of the second list, but
the only potentially useful info that we would have gotten
out of it is really the author and committer of each email.
Since this has been considered marginal in usefulness,
we did not choose this option.
One option for the future is to make this email entirely
configurable, one way or the other. We are not providing
this functionality for now, because there hasn't been
any demand for it. There is also value in having a certain
amount of consistency across projects.
PARAMETERS
None.
RETURN VALUE
A string containing the summary of changes.
"""
summary = []
# Display the lost commits (if any) first, to increase
# our chances of attracting the reader's attention.
if self.lost_commits:
summary.append("")
summary.append("")
summary.append(
"!!! WARNING: THE FOLLOWING COMMITS ARE" " NO LONGER ACCESSIBLE (LOST):"
)
summary.append(
"--------------------------------------" "-----------------------------"
)
summary.append("")
for commit in reversed(self.lost_commits):
# Note: See this function's description for the reasons
# behind only displaying the list of commits using
# their oneline representation.
summary.append(" " + commit.oneline_str())
if self.new_commits_for_ref:
has_silent = False
summary.append("")
summary.append("")
summary.append("Summary of changes (added commits):")
summary.append("-----------------------------------")
summary.append("")
# Note that we want the summary to include all commits
# now accessible from this reference, not just the new
# ones.
for commit in reversed(self.new_commits_for_ref):
if commit.send_email_p:
marker = ""
else:
marker = " (*)"
has_silent = True
# Note: See this function's description for the reasons
# behind only displaying the list of commits using
# their oneline representation.
summary.append(" " + commit.oneline_str() + marker)
# If we added a ' (*)' marker to at least one commit,
# add a footnote explaining what it means.
if has_silent:
if (
self.search_config_option_list("hooks.email-new-commits-only")
is not None
):
summary.extend(
[
"",
"(*) This commit already exists in another branch.",
" Because the reference `{ref_name}' matches".format(
ref_name=self.ref_name
),
" your hooks.email-new-commits-only configuration,",
" no separate email is sent for this commit.",
]
)
else:
summary.extend(
[
"",
"(*) This commit exists in a branch whose name" " matches",
" the hooks.noemail config option. No separate" " email",
" sent.",
]
)
# We do not want that summary to be used for filing purposes.
# So add a "Diff:" marker.
if summary:
# We know that the output starts with a couple of
# empty strings. To avoid too much of a break between
# the "Diff:" marker and the rest of the summary,
# replace the first empty line with our diff marker
# (instead of pre-pending it, for instance).
summary[0] = "\n\nDiff:"
return "\n".join(summary)
def everyone_emails(self):
"""Return the list of email addresses listing everyone possible."""
return expanded_mailing_list(self.ref_name, None)
# ------------------------
# -- Private methods. --
# ------------------------
@property
def new_commits_for_ref(self):
"""The new_commits_for_ref attribute, lazy-initialized."""
if self.__new_commits_for_ref is None:
self.__new_commits_for_ref = self.__get_added_commits()
return self.__new_commits_for_ref
@property
def commits_to_check(self):
"""The commits_to_check attribute, lazy-initialized."""
if self.__commits_to_check is None:
self.__commits_to_check = [
commit
for commit in self.new_commits_for_ref
if self.__check_commit_p(commit)
]
return self.__commits_to_check
@property
def lost_commits(self):
"""The lost_commits attribute, lazy initialized."""
if self.__lost_commits is None:
self.__lost_commits = self.__get_lost_commits()
return self.__lost_commits
@property
def send_cover_email_to_filer(self):
"""True iff the cover email should be sent to hooks.filer-email
By default, the cover email is just an informational email
which is not specific to any particular TN, and thus should
not be filed. So return False by default.
"""
return False
def commit_data_for_hook(self, commit):
"""Return a dict with information about the given commit.
The intention for this dictionary is to be passed to thirdparty
hooks written by repository owners (see, for instance, the
hooks.commit-extra-checker config option). We want to provide
all the information we already have, so the hook doesn't have
to recompute that information again.
The contents of this dictionary follows what's documented in
README.md for the hooks.commit-extra-checker config option.
PARAMETERS
commit: A CommitInfo object.
"""
return {
"rev": commit.rev,
"ref_name": self.ref_name,
"ref_kind": self.ref_kind.value,
"object_type": self.object_type,
"author_name": commit.author_name,
"author_email": commit.author_email,
"subject": commit.subject,
"body": commit.raw_revlog,
}
def call_project_specific_commit_checker(self):
"""Call hooks.commit-extra-checker for all added commits (if set).
Raise InvalidUpdate with the associated error message if the hook
returned nonzero. Just print the hook's output otherwise.
"""
commit_checker_hook = ThirdPartyHook("hooks.commit-extra-checker")
if not commit_checker_hook.defined_p:
return
for commit in self.commits_to_check:
hook_exe, p, out = commit_checker_hook.call(
hook_input=json.dumps(self.commit_data_for_hook(commit)),
hook_args=(self.ref_name, commit.rev),
)
if p.returncode != 0:
# Python-2.x compatibility: When the list of arguments in a call
# are so long that they get formatted over multiple lines, black
# adds a comma at the end of the last argument. Unfortunately,
# it looks like Python 2.x doesn't like that comma when one of
# the parameter is a non-keyworded variable-length argument list
# (aka *args).
#
# So, work around this issue until the transition to Python 3.x
# by building a list with all the unnamed arguments, and then
# use that list to make the call, thus ensuring that the call
# is short-enough to fit in a single line.
invalid_update_msg = [
"The following commit was rejected by your"
" hooks.commit-extra-checker script"
" (status: {p.returncode})".format(p=p),
"commit: {commit.rev}".format(commit=commit),
] + out.splitlines()
raise InvalidUpdate(*invalid_update_msg)
else:
sys.stdout.write(out)
def __get_added_commits(self):
"""Return a list of CommitInfo objects added by our update.
RETURN VALUE
A list of CommitInfo objects, or the empty list if
the update did not introduce any new commit.
"""
if is_null_rev(self.new_rev):
return []
# Compute the list of commits that are not accessible from
# any of the references. These are the commits which are
# new in the repository.
#
# Note that we do not use the commit_info_list function for
# that, because we only need the commit hashes, and a list
# of commit hashes is more convenient for what we want to do
# than a list of CommitInfo objects.
exclude = [
"^%s" % self.all_refs[ref_name]
for ref_name in self.all_refs.keys()
if ref_name != self.ref_name
]
if not is_null_rev(self.old_rev):
exclude.append("^%s" % self.old_rev)
new_repo_revs = git.rev_list(
self.new_rev, *exclude, reverse=True, _split_lines=True, _decode=True
)
# If this is a reference creation (base_rev is null), try to
# find a commit which can serve as base_rev. We try to find
# a pre-existing commit making the base_rev..new_rev list
# as short as possible.
base_rev = self.old_rev
if is_null_rev(base_rev):
if len(new_repo_revs) > 0:
# The ref update brings some new commits. The first
# parent of the oldest of those commits, if it exists,
# seems like a good candidate. If it does not exist,
# we are pushing an entirely new headless branch, and
# base_rev should remain null.
parents = commit_parents(new_repo_revs[0])
if parents:
base_rev = parents[0]
else:
# This reference update does not bring any new commits
# at all. This means new_rev is already accessible
# through one of the references, thus making it a good
# base_rev as well.
base_rev = self.new_rev
# Expand base_rev..new_rev to compute the list of commits which
# are new for the reference. If there is no actual base_rev
# (Eg. a headless branch), then expand to all commits accessible
# from that reference.
if not is_null_rev(base_rev):
commit_list = commit_info_list(self.new_rev, "^%s" % base_rev)
base_rev = commit_rev(base_rev)
else:
commit_list = commit_info_list(self.new_rev)
base_rev = None
# Iterate over every commit, and set their pre_existing_p attribute.
for commit in commit_list:
commit.pre_existing_p = commit.rev not in new_repo_revs
debug("update base: %s" % base_rev)
return commit_list
def __get_lost_commits(self):
"""Return a list of CommitInfo objects lost after our update.
RETURN VALUE
A list of CommitInfo objects, or the empty list if
the update did not cause any commit to be lost.
"""
if is_null_rev(self.old_rev):
# We are creating a new reference, so we cannot possibly
# be losing commits.
return []
# The list of lost commits is computed by listing all commits
# accessible from the old_rev, but not from any of the references.
exclude = ["^%s" % self.all_refs[rev] for rev in self.all_refs.keys()]
commit_list = commit_info_list(self.old_rev, *exclude)
return commit_list
def __check_commit_p(self, commit):
"""Return True if checks on the commit should be done; False if not.
The purpose of this routine is to centralize the logic being used
to determine whether a given commit should be subject to the various
checks we apply to new commits, or not.
commit: A CommitInfo object.
"""
if commit.pre_existing_p:
# This commit already exists in the repository, so we should
# normally not check it. Otherwise, we could run the risk of
# failing a check for a commit which was fine before but no
# longer follows more recent policies. This would cause problems
# when trying to create new references, for instance.
#
# Also, if we started checking pre-existing commits, this could
# add up very quickly in situation where new branches are created
# from branches that already have many commits.
if is_null_rev(self.old_rev):
# It is possible that the user may have requested that all
# new commits in our reference be checked (see below) but,
# since this is a new branch, we ignore that option for
# pre-existing commits (otherwise, the same commits would be
# perpetually be re-checked each time a new branch is created).
return False
elif (
self.search_config_option_list("hooks.force-precommit-checks")
is not None
):
# The user explicitly requested that all new commits on
# this reference much always be checked.
return True
return False
if commit.is_revert():
# We have decided that revert commits should not be subject
# to any check (QB08-047). This allows users to quickly revert
# a commit if need be, without having to worry about bumping
# into any check of any kind.
debug(
"revert commit detected,"
" all checks disabled for this commit: %s" % commit.rev
)
return False
# All other commits should be checked.
return True
def __check_max_commit_emails(self):
"""Raise InvalidUpdate is this update will generate too many emails."""
# Determine the number of commit emails that would be sent if
# we accept the update, and raise an error if it exceeds
# the maximum number of emails.
self.__set_send_email_p_attr(self.new_commits_for_ref)
nb_emails = len(
[commit for commit in self.new_commits_for_ref if commit.send_email_p]
)
max_emails = git_config("hooks.max-commit-emails")
if nb_emails > max_emails:
raise InvalidUpdate(
"This update introduces too many new commits (%d),"
" which would" % nb_emails,
"trigger as many emails, exceeding the"
" current limit (%d)." % max_emails,
"Contact your repository adminstrator if you really meant",
"to generate this many commit emails.",
)
def __do_style_checks(self):
if self.search_config_option_list("hooks.no-style-checks") is not None:
# The respository has been configured to disable all style
# checks on this branch.
debug("no style check on this branch (hooks.no-style-checks)")
return
added = self.commits_to_check
if not added:
# There might be some new commits (e.g. it could be commits
# explicitly excluded, such as "revert" commits), but none
# of those commits need to be checked, and consequently
# no style-checking to be done.
return
if git_config("hooks.combined-style-checking"):
# This project prefers to perform the style check on
# the cumulated diff, rather than commit-per-commit.
# Behave as if the update only added one commit (new_rev),
# with a single parent being old_rev. If old_rev is nul
# (branch creation), then use the first parent of the oldest
# added commit.
debug("(combined style checking)")
if not added[-1].pre_existing_p:
base_rev = (
added[0].base_rev_for_git()
if is_null_rev(self.old_rev)
else self.old_rev
)
style_check_commit(base_rev, self.new_rev, self.email_info.project_name)
else:
debug("(commit-per-commit style checking)")
# Perform the pre-commit checks, as needed...
for commit in added:
style_check_commit(
commit.base_rev_for_git(), commit.rev, self.email_info.project_name
)
def __email_ref_update(self):
"""Send the email describing to the reference update.
This email can be seen as a "cover email", or a quick summary
of the update that was performed.
REMARKS
The hooks may decide that such an email may not be necessary,
and thus send nothing. See self.get_update_email_contents
for more details.
"""
update_email_contents = self.get_update_email_contents()
if update_email_contents is not None:
(email_to, subject, body) = update_email_contents
email_bcc = (
git_config("hooks.filer-email")
if self.send_cover_email_to_filer
else None
)
update_email = Email(
self.email_info,
email_to,
email_bcc,
subject,
body,
None,
self.ref_name,
self.old_rev,
self.new_rev,
)
update_email.enqueue()
def __maybe_get_email_custom_contents(
self, commit, default_subject, default_body, default_diff
):
"""Return an EmailCustomContents for the given commit, if applicable.
For projects that define the hooks.commit-email-formatter config
option, this method calls the script it points to, and builds
an EmailCustomContents object from the data returned by the script.
Errors are handled as gracefully as possible, by returning
an EmailCustomContents which only changes the body of the email
by adding a warning section describing the error that occurred.
Returns None if the hooks.commit-email-formatter is not defined
by the project.
PARAMETERS
commit: A CommitInfo object.
default_subject: The email's subject to use by default
unless overriden by the commit-email-formatter hook.
default_body: The email's body to use by default, unless
overriden by the commit-email-formatter hook.
default_diff: The email's "Diff:" section to use by default,
unless overriden by the commit-email-formatter hook.
"""
email_contents_hook = ThirdPartyHook("hooks.commit-email-formatter")
if not email_contents_hook.defined_p:
return None
hooks_data = self.commit_data_for_hook(commit)
hooks_data["email_default_subject"] = default_subject
hooks_data["email_default_body"] = default_body
hooks_data["email_default_diff"] = default_diff
hook_exe, p, out = email_contents_hook.call(
hook_input=json.dumps(hooks_data),
hook_args=(self.ref_name, commit.rev),
)
def standard_email_due_to_error(err_msg):
"""Return an EmailCustomContents with the given err_msg.
This function allows us to perform consistent error handling
when trying to call the hooks.commit-email-formatter script.
It returns an EmailCustomContents where nothing is changed
(and therefore the standard email gets sent) except for
the addition of a warning section at the end of the email's
body (just before the "Diff:" section). This warning section
indicates that an error was detected, and provides information
about it.
PARAMETERS
err_msg: A description of the error that occurred (a string).
"""
appendix = (
"WARNING:\n"
"{err_msg}\n"
"Falling back to default email format.\n"
"\n"