Coverage for src / python_commitlint / rules / footer_rules.py: 98%
80 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-04-28 02:54 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-04-28 02:54 +0000
1"""Rules that validate the footer block (BREAKING CHANGE, ``token: value``)."""
3import re
5from python_commitlint.core.enums import RuleCondition
6from python_commitlint.core.models import (
7 CommitMessage,
8 RuleConfig,
9 ValidationError,
10)
11from python_commitlint.rules.base import BaseRule, config_value_or
13_FOOTER_TOKEN_PATTERNS: tuple[re.Pattern[str], ...] = (
14 re.compile(r"^BREAKING[- ]CHANGE:"),
15 re.compile(r"^[\w-]+:\s+"),
16 re.compile(r"^[\w-]+\s+#\d+"),
17)
20def _is_footer_token_line(line: str) -> bool:
21 return any(pattern.match(line) for pattern in _FOOTER_TOKEN_PATTERNS)
24class FooterEmptyRule(BaseRule):
25 """Require or forbid an empty footer. Rule name: ``footer-empty``."""
27 @property
28 def name(self) -> str:
29 return "footer-empty"
31 def validate(
32 self, commit: CommitMessage, config: RuleConfig
33 ) -> ValidationError | None:
34 is_empty = not commit.footer
35 should_be_empty = config.condition == RuleCondition.ALWAYS
37 if is_empty != should_be_empty:
38 msg = (
39 "footer may not be empty"
40 if not should_be_empty
41 else "footer must be empty"
42 )
43 return self._create_error(config, msg)
44 return None
47class FooterLeadingBlankRule(BaseRule):
48 """Require a blank line before the footer. Rule name: ``footer-leading-blank``."""
50 @property
51 def name(self) -> str:
52 return "footer-leading-blank"
54 def validate(
55 self, commit: CommitMessage, config: RuleConfig
56 ) -> ValidationError | None:
57 if not commit.footer:
58 return None
60 lines = commit.raw.split("\n")
61 footer_start = self._find_footer_start(lines)
63 if footer_start > 0:
64 has_blank = not lines[footer_start - 1].strip()
65 should_have_blank = config.condition == RuleCondition.ALWAYS
67 if has_blank != should_have_blank:
68 msg = (
69 "footer must have leading blank line"
70 if should_have_blank
71 else "footer must not have leading blank line"
72 )
73 return self._create_error(config, msg)
74 return None
76 def _find_footer_start(self, lines: list[str]) -> int:
77 # Skip line 0 (the header) — its `type: subject` shape would
78 # otherwise be mistaken for a footer token.
79 for i in range(1, len(lines)):
80 line = lines[i]
81 if line and _is_footer_token_line(line):
82 return i
83 return -1
86class FooterMaxLengthRule(BaseRule):
87 """Enforce a maximum total footer length. Rule name: ``footer-max-length``."""
89 @property
90 def name(self) -> str:
91 return "footer-max-length"
93 def validate(
94 self, commit: CommitMessage, config: RuleConfig
95 ) -> ValidationError | None:
96 if not commit.footer:
97 return None
99 max_length = config_value_or(config, float("inf"))
100 is_valid = len(commit.footer) <= max_length
101 should_be_valid = config.condition == RuleCondition.ALWAYS
103 if is_valid != should_be_valid:
104 return self._create_error(
105 config, f"footer must be at most {max_length} characters"
106 )
107 return None
110class FooterMaxLineLengthRule(BaseRule):
111 """Enforce a per-line footer length limit. Rule name: ``footer-max-line-length``."""
113 @property
114 def name(self) -> str:
115 return "footer-max-line-length"
117 def validate(
118 self, commit: CommitMessage, config: RuleConfig
119 ) -> ValidationError | None:
120 if not commit.footer:
121 return None
123 # Only the ALWAYS polarity is meaningful for a max-line-length
124 # rule — "lines must NEVER be at most N characters" is nonsensical,
125 # so under NEVER this rule is a no-op.
126 if config.condition != RuleCondition.ALWAYS:
127 return None
129 max_length = config_value_or(config, float("inf"))
130 for line in commit.footer.split("\n"):
131 if len(line) > max_length:
132 return self._create_error(
133 config,
134 f"footer lines must be at most {max_length} characters",
135 )
136 return None
139class FooterMinLengthRule(BaseRule):
140 """Enforce a minimum total footer length. Rule name: ``footer-min-length``."""
142 @property
143 def name(self) -> str:
144 return "footer-min-length"
146 def validate(
147 self, commit: CommitMessage, config: RuleConfig
148 ) -> ValidationError | None:
149 if not commit.footer:
150 return None
152 min_length = config_value_or(config, 0)
153 is_valid = len(commit.footer) >= min_length
154 should_be_valid = config.condition == RuleCondition.ALWAYS
156 if is_valid != should_be_valid:
157 return self._create_error(
158 config, f"footer must be at least {min_length} characters"
159 )
160 return None