Repository navigation
Expand file tree
/
Copy pathengine.py
More file actions
1752 lines (1474 loc) · 70 KB
/
Copy pathengine.py
File metadata and controls
1752 lines (1474 loc) · 70 KB
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
"""Clean validation engine following SOLID principles."""
from __future__ import annotations
import re
import shlex
import subprocess
import sys
from abc import ABC, abstractmethod
from collections.abc import Iterable
from dataclasses import dataclass, field
from enum import IntEnum
from fnmatch import fnmatchcase
from typing import ClassVar
from commit_check import DEFAULT_AI_DISCLOSURE_TRAILERS, ai_policy
from commit_check.ai_signatures import (
detect_ai_signatures,
)
from commit_check.fixes import (
fix_branch_type,
fix_conventional_header,
fix_subject_case,
fix_wip,
signoff_trailer,
strip_lines_containing,
)
from commit_check.imperatives import IMPERATIVES, NON_IMPERATIVE_LOOKALIKES
from commit_check.rule_builder import ValidationRule
from commit_check.util import (
_print_failure,
fetch_remote_ref,
fetch_upstream_ref,
format_size,
get_branch_name,
get_commit_author_identity,
get_commit_files,
get_commit_info,
get_git_config_value,
get_git_remotes,
get_git_user_identity,
get_push_commits,
get_tags_at,
get_upstream_branch,
get_upstream_remote_sha,
git_merge_base,
git_rev_parse_verify,
has_commits,
print_error_header,
rejection_headline,
)
class ValidationResult(IntEnum):
"""Validation result codes.
``SKIP`` means the validator declined to run — the author is on an
ignore list, or there was nothing to check — as opposed to ``PASS``,
which means the rule ran and found nothing to object to. Reporting a
skip as a pass makes a bypassed policy indistinguishable from an
enforced one, so the two are kept apart.
Only ``FAIL`` is an error. ``validate_all`` returns ``PASS``/``FAIL``
explicitly rather than propagating this value, so the new member never
reaches an exit code.
"""
PASS = 0
FAIL = 1
SKIP = 2
@dataclass(frozen=True)
class ValidationContext:
"""Context for validation operations."""
stdin_text: str | None = None
commit_file: str | None = None
config: dict = field(default_factory=dict)
no_banner: bool = False
compact: bool = False
push_upstream_fallback: bool = False
# A git revision naming the commit under test. When set, message and
# author checks read that commit -- the author is the commit's author,
# never the local git config, because an existing commit's identity is
# a fact about the commit rather than about whoever is running the
# check. The CLI verifies the revision resolves before it gets here.
# Last on purpose: positional construction predates it.
rev: str | None = None
# Per-run memo of get_commit_files() keyed by revision. The three file
# rules each get their own validator, so without this they would re-run
# the same git plumbing over the same commits three times.
files_cache: dict[str, list[tuple[str, int]]] = field(
default_factory=dict, repr=False, compare=False
)
# Set when stdin_text is a piped commit message: checks with a value of
# their own (branch, author, tags, push refs) then read git instead.
stdin_is_message: bool = False
# Set when stdin_text is the pre-push line rebuilt from pre-commit's
# environment. pre-commit names one ref per push, the first that carries
# commits the remote lacks, so a ref pushed alongside it -- a tag that
# ``git push --follow-tags`` sends after a branch -- is not in the line.
push_from_pre_commit: bool = False
@property
def piped_value(self) -> str | None:
"""stdin_text as a check's own value, or None when it is the message."""
return None if self.stdin_is_message else self.stdin_text
@property
def piped_name(self) -> str | None:
"""piped_value as the one name a branch or author check reads.
None as well when stdin_text is the pre-push line rebuilt from
pre-commit's environment: that names refs for the push checks run
alongside, not a branch or an author, so these read git instead.
"""
return None if self.push_from_pre_commit else self.piped_value
@dataclass
class CheckOutcome:
"""Structured result of a single validation check.
Returned by :meth:`ValidationEngine.validate_all_detailed` so that
callers (e.g. ``--format json`` output, the Python API) can inspect
individual check results without parsing human-readable terminal output.
"""
check: str
# "pass" (the rule ran and was satisfied), "fail" (the rule ran and was
# not), "warn" (the rule was not satisfied but is listed under ``warn``
# in the config, so it is reported and does not fail the run), or "skip"
# (the rule never ran — ignored author, or nothing to check). A skip is
# not a pass: it means the policy was bypassed, and collapsing the two
# lets a run that validated nothing report success.
status: str
# The concrete value that was checked (subject, branch, author, ...),
# populated on both pass and fail so consumers can report what was
# validated even when the check succeeded.
value: str = ""
error: str = ""
suggest: str = ""
# The corrected value when the fix is unambiguous (a type's case, a WIP
# marker, a missing trailer); empty when fixing it takes a judgment the
# tool should not make. Consumers can offer it as a one-step correction.
fix: str = ""
rule_id: str = ""
docs_url: str = ""
# Why a skipped check did not run, when commit-check knows (the author
# is on an ``ignore_authors`` list); empty otherwise.
reason: str = ""
def to_dict(self) -> dict[str, str]:
"""Serialise to a plain dict (suitable for JSON encoding)."""
return {
"rule_id": self.rule_id,
"check": self.check,
"status": self.status,
"value": self.value,
"error": self.error,
"suggest": self.suggest,
"fix": self.fix,
"docs_url": self.docs_url,
"reason": self.reason,
}
def overall_status(statuses: Iterable[str]) -> str:
"""Reduce per-check statuses to one of ``"pass"``/``"fail"``/``"skip"``.
Takes plain status strings rather than a specific type so that every
caller can share it: the CLI's ``--format json`` and the API's
:class:`CheckOutcome` objects, and the API's combined paths
(``validate_author`` with both inputs, ``validate_all``) which merge
already-serialised check dicts.
That breadth is the point. This rule had been copied into four places,
and each copy defaulted to ``"pass"`` for anything that was not a
failure — which is how a fully skipped run kept reporting success even
after the skip status existed.
``"skip"`` requires that *every* check skipped: a single real verdict
means something was actually validated. Only ``"fail"`` is an error: a
``"warn"`` is a verdict the config asked to report without enforcing,
so a run whose only findings are warnings passes.
"""
seen = list(statuses)
if any(s == "fail" for s in seen):
return "fail"
if seen and all(s == "skip" for s in seen):
return "skip"
return "pass"
def count_warnings(statuses: Iterable[str]) -> int:
"""How many checks were reported as warnings rather than failures."""
return sum(1 for s in statuses if s == "warn")
class BaseValidator(ABC):
"""Abstract base validator."""
def __init__(self, rule: ValidationRule):
self.rule = rule
# Set to True by the engine to suppress human-readable terminal
# output while still collecting failure details: validate_all_detailed
# never prints, validate_all prints the collected blocks itself once
# every rule has run.
self._suppress_output: bool = False
# Used by _print_failure() when this validator prints for itself.
self._no_banner: bool = False
self._compact: bool = False
# Populated by _print_failure() on every failure, regardless of mode.
self._last_failure: dict[str, str] | None = None
# The failure blocks as the printer takes them, (rule dict, value),
# kept so the engine can print them after the banner.
self._failure_blocks: list[tuple[dict, str]] = []
# Populated by subclasses on every validation (pass or fail) with the
# concrete value that was checked (subject, branch, author, ...), so
# structured consumers (--format json, validate_all_detailed) can
# report what was checked even when the check passed.
self._checked_value: str = ""
# Set by ValidationEngine.validate_all_detailed() to opt into value
# collection. Text-mode validation skips the extra lookups (e.g. a
# git subprocess for the branch name) and keeps values empty.
self._collect_value: bool = False
# Why the rule declined to run, when the reason is something the
# user configured (an ignored author). A bare "skipped" leaves the
# reader guessing whether the policy was bypassed on purpose.
self._skip_reason: str = ""
@abstractmethod
def validate(self, context: ValidationContext) -> ValidationResult:
"""Perform validation and return result."""
@staticmethod
def _resolve_current_author(context: ValidationContext) -> str:
"""Resolve the relevant author identity based on validation mode.
Two distinct modes:
*Prospective message* (``stdin_text`` or ``commit_file`` is set):
the user is about to create a new commit. The last commit's author
is unrelated — the relevant identity is the local git config
(``user.name``), i.e. the person who will author the pending commit.
*Existing commit* (no ``stdin_text``, no ``commit_file``):
the last commit is the one being validated. Use its own author
(``get_commit_info("an")``), not the local git config which may
belong to a different person.
"""
if context.rev is not None:
# An explicit revision names an existing commit; its author is a
# fact about that commit, so the config never enters into it.
return get_commit_info("an", context.rev)
if context.stdin_text is not None or context.commit_file is not None:
return get_git_config_value("user.name") or get_commit_info("an")
return get_commit_info("an") or get_git_config_value("user.name")
@staticmethod
def _message_was_supplied(context: ValidationContext) -> bool:
"""Whether the caller named a message source rather than leaving it to git.
Distinguishes "you asked me about this empty message" from "git had
nothing to give me", which decide opposite answers: the first is a
message that fails, the second is nothing to check.
A commit_file that cannot be read counts as named even though the text
then comes from git. That stays correct where it matters: the only way
to reach an empty message from there is a HEAD commit whose message is
genuinely empty, and rejecting that under allow_empty_commits = false
is the verdict the rule exists to give.
"""
return (
context.stdin_text is not None
or context.commit_file is not None
or context.rev is not None
)
@staticmethod
def _get_commit_message(context: ValidationContext) -> str:
"""Get commit message from context or git."""
if context.stdin_text is not None:
return context.stdin_text.strip()
if context.commit_file:
try:
with open(context.commit_file, "r", encoding="utf-8") as f:
return f.read().strip()
except FileNotFoundError:
pass
# Fallback to git log
if context.rev is not None:
subject = get_commit_info("s", context.rev)
body = get_commit_info("b", context.rev)
else:
subject = get_commit_info("s")
body = get_commit_info("b")
return f"{subject}\n\n{body}".strip()
def _author_in_ignore_list(self, context: ValidationContext) -> bool:
"""Check if the current author or any co-author is in the ignore list."""
ignore_authors = context.config.get("commit", {}).get("ignore_authors", [])
if not ignore_authors:
return False
current_author = self._resolve_current_author(context)
if current_author and current_author in ignore_authors:
self._skip_reason = f"author {current_author} is in [commit].ignore_authors"
return True
# Check co-authors from the commit message body
message = self._get_commit_body(context)
if not message:
return False
co_authors = re.findall(
r"^Co-authored-by:\s*([^<\n]+)\s*(?:<|$)",
message,
re.MULTILINE,
)
for co_author in co_authors:
if co_author.strip() in ignore_authors:
self._skip_reason = (
f"co-author {co_author.strip()} is in [commit].ignore_authors"
)
return True
return False
@staticmethod
def _get_commit_body(context: ValidationContext) -> str:
"""Retrieve the commit message body from context or git."""
# An empty string is a message the caller supplied, not an absent one.
# Reading it as absent sends the check off to the repository's HEAD
# commit instead, so a caller asking about "" is answered about
# whatever was committed last. The skip logic above already draws the
# line at None; this follows it.
if context.stdin_text is not None:
return context.stdin_text
if context.commit_file:
try:
with open(context.commit_file, "r", encoding="utf-8") as f:
return f.read()
except OSError:
pass
if context.rev is not None:
return get_commit_info("b", context.rev)
return get_commit_info("b")
def _should_skip_commit_validation(self, context: ValidationContext) -> bool:
"""
Determine if commit validation should be skipped.
Skip if the current author or any co-author is in the ignore_authors list
for commits, or if no stdin_text, no commit_file, and no commits exist.
"""
if self._author_in_ignore_list(context):
return True
return (
context.stdin_text is None
and context.commit_file is None
and context.rev is None
and not has_commits()
)
def _should_skip_branch_validation(self, context: ValidationContext) -> bool:
"""
Determine if branch validation should be skipped.
Skip if the current author is in the ignore_authors list for branches,
or if no branch name was supplied and no commits exist.
"""
ignore_authors = context.config.get("branch", {}).get("ignore_authors", [])
if ignore_authors:
current_author = self._resolve_current_author(context)
if current_author and current_author in ignore_authors:
self._skip_reason = (
f"author {current_author} is in [branch].ignore_authors"
)
return True
return context.piped_name is None and not has_commits()
def _print_failure(
self,
actual_value: str,
*,
error: str | None = None,
fix: str | None = None,
suggest: str | None = None,
) -> None:
"""Record and (unless suppressed) print a standardised failure message.
``error`` replaces the rule's static explanation when the validator
knows more than the rule does at build time: the measured length, the
tools it detected, whether the message under test is a commit yet.
``fix`` is the corrected value when the validator can name it without
guessing; it replaces the rule's generic suggestion with a concrete
one (``suggest`` overrides the wording) and travels to structured
consumers as its own field.
"""
rule_dict = self.rule.to_dict()
if error is not None:
rule_dict["error"] = error
if fix or suggest:
rule_dict["suggest"] = suggest or f'Use "{fix}"'
# Always store structured failure details for programmatic consumers.
self._last_failure = {
"check": self.rule.check,
"value": actual_value,
"error": rule_dict["error"],
"suggest": rule_dict.get("suggest") or self.rule.suggest or "",
"fix": fix or "",
}
self._failure_blocks.append((rule_dict, actual_value))
if not self._suppress_output:
_print_failure(
rule_dict,
actual_value,
no_banner=self._no_banner,
compact=self._compact,
)
class CommitMessageValidator(BaseValidator):
"""Validates commit messages against conventional commit standards."""
def validate(self, context: ValidationContext) -> ValidationResult:
if self._should_skip_commit_validation(context):
return ValidationResult.SKIP
message = self._get_commit_message(context)
if not message:
return ValidationResult.PASS
self._checked_value = message
if self.rule.regex and re.match(self.rule.regex, message):
return ValidationResult.PASS
# Name the correction only when it is mechanical: a type's case or a
# one-letter slip, a colon that went missing. The corrected header
# has to satisfy the rule itself, or it is no fix at all.
subject, newline, rest = message.partition("\n")
fixed_subject = fix_conventional_header(subject, self.rule.allowed)
fix = suggest = None
if (
fixed_subject
and self.rule.regex
and re.match(self.rule.regex, fixed_subject)
):
fix = fixed_subject + newline + rest
suggest = f'Use "{fixed_subject}"'
self._print_failure(message, fix=fix, suggest=suggest)
return ValidationResult.FAIL
class SubjectValidator(BaseValidator):
"""Validates commit subject lines."""
def validate(self, context: ValidationContext) -> ValidationResult:
if self._should_skip_commit_validation(context):
return ValidationResult.SKIP
subject = self._get_subject(context)
if not subject:
return ValidationResult.PASS
self._checked_value = subject
return self._validate_subject(subject)
def _get_subject(self, context: ValidationContext) -> str:
"""Extract subject from commit message."""
if context.stdin_text is not None:
return context.stdin_text.strip().split("\n")[0]
if context.commit_file:
try:
with open(context.commit_file, "r", encoding="utf-8") as f:
message = f.read().strip()
return message.split("\n")[0]
except FileNotFoundError:
pass
if context.rev is not None:
return get_commit_info("s", context.rev)
return get_commit_info("s")
def _validate_subject(self, _subject: str) -> ValidationResult:
"""Override in subclasses for specific validation logic."""
return ValidationResult.PASS
class SubjectCapitalizationValidator(SubjectValidator):
"""Validates that subject starts with capital letter."""
def _validate_subject(self, subject: str) -> ValidationResult:
# A merge subject is machine-written; the rule declines to judge it.
# Git writes "Merge " exactly, so anything else is author prose.
if subject.startswith("Merge "):
return ValidationResult.SKIP
if self._is_capitalized(subject):
return ValidationResult.PASS
# The corrected subject has to pass this same check, or it is no fix.
fix = fix_subject_case(subject, capitalize=True)
if fix and not self._is_capitalized(fix):
fix = None
self._print_failure(subject, fix=fix)
return ValidationResult.FAIL
@staticmethod
def _is_capitalized(subject: str) -> bool:
"""Whether the description starts upper-case, after any Conventional Commits prefix.
Only a prefix that ends in a colon is a type. Without one, the first
word is the description itself, so "Add feature" is judged on its
"A" and not on the "f" that follows, and "Update" alone is judged at
all.
"""
match = re.match(r"^\w+(?:\([^)]*\))?!?:\s*(.*)", subject)
description = match.group(1).strip() if match else subject
return bool(description) and description[0].isupper()
class SubjectImperativeValidator(SubjectValidator):
"""Validates that subject uses imperative mood.
Decides on the first word's form rather than its membership in a
vocabulary: a past tense ("fixed"), a gerund ("adding") or a third person
("fixes") is not imperative, and nothing else disqualifies. A list can
only reject correct subjects wherever it falls short, and it always does:
on 59k strictly imperative subjects from git.git it rejected 45%, against
1% here. See #526.
The loosening is deliberate: a noun-led subject ("parser improvements")
now passes, where the list rejected it by accident of vocabulary.
"""
_INFLECTED = ("ed", "ing")
def _validate_subject(self, subject: str) -> ValidationResult:
# Merge and fixup subjects are machine-written; decline to judge them.
# Git writes "Merge " and "fixup! " exactly, so anything else is
# author prose.
if subject.startswith(("Merge ", "fixup! ")):
return ValidationResult.SKIP
# Extract first word (ignore conventional commit prefixes)
# support breaking changes (feat!:)
match = re.match(r"^(?:\w+(?:\([^)]*\))?!?:\s*)?(\w+)", subject)
if not match:
return ValidationResult.PASS
first_word = match.group(1).lower()
if self._is_inflected(first_word):
self._print_failure(subject)
return ValidationResult.FAIL
return ValidationResult.PASS
@classmethod
def _is_inflected(cls, word: str) -> bool:
"""Whether *word* carries past-tense, gerund or third-person marking."""
if word in NON_IMPERATIVE_LOOKALIKES:
return False
if word.endswith(cls._INFLECTED):
return True
return cls._is_third_person(word)
@classmethod
def _is_third_person(cls, word: str) -> bool:
"""Whether *word* is a verb wearing the third-person singular -s.
Unlike -ed and -ing, a trailing -s is weak evidence on its own --
plural nouns wear one too ("status report") -- so it asks for
corroboration: the stem has to be a verb we already know. "tests" is
genuinely both, and this reads it as the verb.
"""
# "address" and "process" end in -ss without being third person.
if not word.endswith("s") or word.endswith("ss"):
return False
stems = {word[:-1]}
if word.endswith("es"):
stems.add(word[:-2])
if word.endswith("ies"):
stems.add(word[:-3] + "y")
return any(stem in IMPERATIVES for stem in stems)
def _characters(count: int) -> str:
"""``count`` with its unit, singular or plural as the number demands."""
return f"{count} character{'' if count == 1 else 's'}"
class SubjectLengthValidator(SubjectValidator):
"""Validates subject line length constraints."""
def _validate_subject(self, subject: str) -> ValidationResult:
# A merge subject's length is git's doing, not the author's.
if subject.startswith("Merge "):
return ValidationResult.SKIP
length = len(subject)
limit = self.rule.value
if (
(self.rule.check == "subject_max_length" and length <= limit)
or (self.rule.check == "subject_min_length" and length >= limit)
or self.rule.check not in ["subject_max_length", "subject_min_length"]
):
return ValidationResult.PASS
# "At most 20" states the rule; the measured length says how far off
# the subject is, and the difference is what decides between trimming
# a word and rewriting the line.
if self.rule.check == "subject_max_length":
error = f"Subject is {_characters(length)}; it must be at most {_characters(limit)}"
suggest = f"Shorten the subject by {_characters(length - limit)}, to {limit} or fewer"
else:
error = f"Subject is {_characters(length)}; it must be at least {_characters(limit)}"
suggest = f"Write a subject of at least {_characters(limit)} ({limit - length} more)"
self._print_failure(subject, error=error, suggest=suggest)
return ValidationResult.FAIL
class AuthorValidator(BaseValidator):
"""Validates author information."""
def validate(self, context: ValidationContext) -> ValidationResult:
# Use commit skip logic for ignore_authors
if self._should_skip_commit_validation(context):
return ValidationResult.SKIP
author_value = self._get_author_value(context)
if not author_value:
return ValidationResult.PASS
self._checked_value = author_value
return self._validate_author(author_value)
def _get_author_value(self, context: ValidationContext) -> str:
"""Get author value based on rule type.
Checks git config first (for pre-commit validation of the configured identity),
then falls back to the last commit's author info.
"""
supplied = context.piped_name
if supplied is not None:
return supplied.strip()
git_config_map = {
"author_name": "user.name",
"author_email": "user.email",
}
git_log_map = {
"author_name": "an",
"author_email": "ae",
}
# An explicit revision names an existing commit, whose identity is a
# fact about the commit: read it from the commit and never from the
# config, which describes whoever happens to be running the check.
if context.rev is not None:
format_str = git_log_map.get(self.rule.check, "")
return get_commit_info(format_str, context.rev) if format_str else ""
# Try git config first (validates configured identity for new commits)
config_key = git_config_map.get(self.rule.check, "")
if config_key:
config_value = get_git_config_value(config_key)
if config_value:
return config_value
# Fall back to last commit's author info
format_str = git_log_map.get(self.rule.check, "")
return get_commit_info(format_str) if format_str else ""
def _validate_author(self, author_value: str) -> ValidationResult:
"""Validate author against rule constraints."""
if self.rule.regex:
if re.match(self.rule.regex, author_value):
return ValidationResult.PASS
self._print_failure(author_value)
return ValidationResult.FAIL
if self.rule.allowed and author_value not in self.rule.allowed:
self._print_failure(author_value)
return ValidationResult.FAIL
if self.rule.ignored and author_value in self.rule.ignored:
# An ignored author is a deliberate bypass, not a verdict.
return ValidationResult.SKIP
return ValidationResult.PASS
class BranchValidator(BaseValidator):
"""Validates branch names."""
def validate(self, context: ValidationContext) -> ValidationResult:
if self._should_skip_branch_validation(context):
return ValidationResult.SKIP
supplied = context.piped_name
branch_name = supplied.strip() if supplied is not None else get_branch_name()
self._checked_value = branch_name
if not self.rule.regex:
return ValidationResult.PASS
if re.match(self.rule.regex, branch_name):
return ValidationResult.PASS
fixed = fix_branch_type(branch_name, self.rule.allowed)
fix = suggest = None
if fixed and re.match(self.rule.regex, fixed):
fix = fixed
suggest = (
f'Rename the branch to "{fixed}" (git branch -m {shlex.quote(fixed)})'
)
self._print_failure(branch_name, fix=fix, suggest=suggest)
return ValidationResult.FAIL
class TagValidator(BaseValidator):
"""Validates tag names.
Checks every tag pointing at the revision under test (``--rev``, or
``HEAD``). A commit with no tag is a skip, not a failure: the rule
validates how tags are named, and the absence of one is not a naming
violation. Piped input (or an API-supplied value) names the tags to check
directly, one per line, without consulting git. In a pre-push hook the
tags under push are checked instead; under pre-commit, which names only
one ref of a push, a branch push checks the tags on the commits it pushes.
"""
@staticmethod
def _tags_from_stdin(text: str) -> list[str]:
"""Extract tag names from piped input.
Two shapes arrive here: bare tag names (API callers, ``echo v1 |``),
and the four-field ``<local ref> <sha> <remote ref> <sha>`` lines a
pre-push hook receives. For push lines the tags under push are the
``refs/tags/*`` refs — a push carrying no tag ref yields nothing to
validate, and a deletion (all-zero local sha) removes a tag rather
than naming a new one, so neither can fail the check.
"""
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
push_lines = [ln for ln in lines if len(ln.split()) == 4 and "refs/" in ln]
if push_lines and len(push_lines) == len(lines):
tags = []
for ln in push_lines:
local_ref, local_sha, remote_ref, _ = ln.split()
if set(local_sha) == {"0"}:
continue
for ref in (local_ref, remote_ref):
if ref.startswith("refs/tags/"):
tags.append(ref.removeprefix("refs/tags/"))
break
return list(dict.fromkeys(tags))
return lines
@staticmethod
def _tags_on_pushed_commits(text: str) -> list[str]:
"""The tags pointing at any commit pre-push lines carry.
Every commit of the push counts, not only the tip: a release commit
is often followed by another before the push, and
``git push --follow-tags`` still sends the tag on the earlier one.
"""
tags = []
for ln in text.splitlines():
fields = ln.split()
if len(fields) == 4 and set(fields[1]) != {"0"}:
for rev in get_push_commits(fields[1], fields[3]):
tags.extend(get_tags_at(rev))
return list(dict.fromkeys(tags))
def validate(self, context: ValidationContext) -> ValidationResult:
supplied = context.piped_value
if supplied is not None:
tags = self._tags_from_stdin(supplied)
if not tags and context.push_from_pre_commit:
# A branch push, as pre-commit tells it: any tag pushed with
# the branch is missing from the line, so the tags on the
# pushed commits stand in for it. Reading HEAD instead would
# judge whatever is checked out, which need not be pushed.
tags = self._tags_on_pushed_commits(supplied)
else:
tags = get_tags_at(context.rev or "HEAD")
if not tags:
return ValidationResult.SKIP
self._checked_value = ", ".join(tags)
if not self.rule.regex:
return ValidationResult.PASS
for tag in tags:
if not re.match(self.rule.regex, tag):
self._checked_value = tag
self._print_failure(tag)
return ValidationResult.FAIL
return ValidationResult.PASS
class FilesValidator(BaseValidator):
"""Validates metadata about the files a commit touches.
One class serves the three file rules — size limit, prohibited path
patterns, path length — branching on the rule's check name. Only the
paths and sizes recorded in the commit are read, never file contents:
content scanning is a different tool's job. A commit touching no files
(or an unresolvable revision) is a skip.
"""
@staticmethod
def _push_revs_from_stdin(text: str) -> list[str] | None:
"""Extract the commits a pre-push hook is being asked to approve.
A native pre-push hook receives ``<local ref> <sha> <remote ref>
<sha>`` lines naming what is being pushed; validating HEAD there
would check the wrong commit whenever another ref is pushed. Both
ends of each line matter: a push usually carries several commits,
and checking only the tip would wave through a file added by any
earlier commit in the same push.
Deletions are skipped: an all-zero local sha removes content rather
than adding it.
Tags go through the same range as branches rather than being
skipped. What matters is not whether a ref is a tag but whether it
carries commits the remote lacks: a tag on already-pushed history
resolves to an empty range, so ``git push --follow-tags`` is not
rejected over a file committed long before, while a tag that is the
only thing carrying a commit to the remote still gets that commit
checked. Tag *names* remain CC401's business.
Input of any other shape is not push metadata and returns ``None``,
so the validator falls back to the revision under test.
"""
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
push_lines = [ln for ln in lines if len(ln.split()) == 4 and "refs/" in ln]
if not push_lines or len(push_lines) != len(lines):
return None
revs: list[str] = []
for ln in push_lines:
_local_ref, local_sha, _remote_ref, remote_sha = ln.split()
if set(local_sha) == {"0"}:
continue
revs.extend(get_push_commits(local_sha, remote_sha))
return list(dict.fromkeys(revs))
def _files_for_rev(
self, context: ValidationContext, rev: str
) -> list[tuple[str, int]]:
"""Files touched by *rev*, computed once per run and shared."""
if rev not in context.files_cache:
context.files_cache[rev] = get_commit_files(rev)
return context.files_cache[rev]
def _revs_to_check(self, context: ValidationContext) -> list[str] | None:
"""Revisions this run should police.
``None`` means the push carried nothing to police — only deletions,
or commits the remote already has — which the caller reports as a
skip rather than a pass.
"""
supplied = context.piped_value
if supplied is not None:
revs = self._push_revs_from_stdin(supplied)
if revs is not None:
return revs or None
return [context.rev or "HEAD"]
def _collect_files(
self, context: ValidationContext, revs: list[str]
) -> list[tuple[str, int]]:
"""Files across *revs*, each one counted once.
A file touched by several commits of the same push is one offender,
not one per commit.
"""
files: list[tuple[str, int]] = []
seen: set[tuple[str, int]] = set()
for rev in revs:
for item in self._files_for_rev(context, rev):
if item not in seen:
seen.add(item)
files.append(item)
return files
def validate(self, context: ValidationContext) -> ValidationResult:
revs = self._revs_to_check(context)
if revs is None:
return ValidationResult.SKIP
files = self._collect_files(context, revs)
if not files:
return ValidationResult.SKIP
self._checked_value = f"{len(files)} file(s)"
if self.rule.check == "file_size":
return self._validate_sizes(files)
if self.rule.check == "file_pattern":
return self._validate_patterns(files)
if self.rule.check == "path_length":
return self._validate_path_lengths(files)
return ValidationResult.PASS
def _fail(self, offenders: list[str]) -> ValidationResult:
"""Report the first offender, with a count when there are more."""
value = offenders[0]
if len(offenders) > 1:
value += f" (+{len(offenders) - 1} more)"
self._checked_value = value
self._print_failure(value)
return ValidationResult.FAIL
def _validate_sizes(self, files: list[tuple[str, int]]) -> ValidationResult:
limit = self.rule.value
offenders = [
f"{path} ({format_size(size)})" for path, size in files if size > limit
]
if offenders:
return self._fail(offenders)
return ValidationResult.PASS
def _validate_patterns(self, files: list[tuple[str, int]]) -> ValidationResult:
# fnmatch() folds case through os.path.normcase, which would make
# "*.pem" catch KEY.PEM on Windows and miss it everywhere else --
# one config, two policies. fnmatchcase() is the same on every
# platform, and case-sensitive is what git pathspecs already are.
patterns = self.rule.value or []
offenders = []
for path, _ in files:
basename = path.rsplit("/", 1)[-1]
hit = next(
(
pattern
for pattern in patterns
# A bare pattern like *.pem should catch the file at any
# depth, so the basename is matched alongside the full
# path.
if fnmatchcase(path, pattern) or fnmatchcase(basename, pattern)
),
None,
)
if hit:
offenders.append(f"{path} (pattern {hit})")
if offenders:
return self._fail(offenders)
return ValidationResult.PASS
def _validate_path_lengths(self, files: list[tuple[str, int]]) -> ValidationResult:
limit = self.rule.value
offenders = [
f"{path} ({len(path)} characters)" for path, _ in files if len(path) > limit
]
if offenders:
return self._fail(offenders)
return ValidationResult.PASS
class MergeBaseValidator(BaseValidator):
"""Validates merge base ancestry."""
def validate(self, context: ValidationContext) -> ValidationResult:
if self._should_skip_branch_validation(context):
return ValidationResult.SKIP
current_branch = get_branch_name()
target_pattern = self.rule.regex
self._checked_value = current_branch
if not target_pattern:
return ValidationResult.PASS
# Find target branch matching the pattern
target_branch = self._find_target_branch(target_pattern)
if not target_branch:
return ValidationResult.PASS
result = git_merge_base(target_branch, current_branch)
if result == 128:
# 128 is git failing to resolve a name, not an answer about
# ancestry. A CI checkout of a pull request leaves a detached HEAD
# with no local branch created, while get_branch_name() still