Coverage for src/hooks/prepare_commit_message.py: 0.00%
116 statements
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-08 13:49 +0000
« prev ^ index » next coverage.py v7.17.2, created at 2026-11-08 13:49 +0000
1#!/usr/bin/env python3
3# Standard libraries
4from os import environ, sep
5from pathlib import Path
6from re import match, search
7import sys
8from sys import argv, exit as sys_exit, path
9from typing import Any, Dict, List, NamedTuple, Optional, Tuple
11# Modules libraries
12from yaml import safe_load as yaml_load
14# Bind sources
15sys.dont_write_bytecode = True
16path.append(str(Path(__file__).resolve().parent))
18# Components, pylint: disable=import-error,wrong-import-position
19from configurations import (
20 COMMITIZEN_CONFIGURATION_FILE,
21 COMMITS_CHANGES_PATTERN,
22 COMMITS_CHANGES_SECTION,
23 COMMITS_COMMENTS_PREFIX,
24 COMMITS_DEFAULT_BODY,
25 COMMITS_DEFAULT_SCOPE,
26 COMMITS_DEFAULT_SUBJECT,
27 COMMITS_DEFAULT_TYPE,
28 CHANGES_EVALUATORS,
29 COMMITS_FOOTER_SIGNOFF,
30 CHANGES_MATCHERS,
31 COMMITS_MESSAGE_EOL,
32)
34# Data type
35Data = Dict[str, Any]
37# Parse commitizen configuration
38def parse_commitizen_configurations() -> bool:
40 # Variables
41 always_signoff: bool = False
42 configuration: str
43 data: Data
45 # Detect commitizen configuration
46 if 'PWD' in environ:
47 configuration = f"{environ['PWD']}{sep}{COMMITIZEN_CONFIGURATION_FILE}"
48 else:
49 configuration = COMMITIZEN_CONFIGURATION_FILE
51 # Parse commitizen configuration
52 if Path(configuration).exists():
53 with Path(configuration).open(encoding='utf8', mode='r') as file:
54 data = yaml_load(file)
56 # Extract always_signoff
57 if 'commitizen' in data and 'always_signoff' in data['commitizen']:
58 always_signoff = bool(data['commitizen']['always_signoff'])
60 # Result
61 return always_signoff
63# Parse commit input
64def parse_commit_input(filepath: str) -> Tuple[bool, bool, str, List[str]]:
66 # Variables
67 capture: bool = False
68 changes: List[str] = []
69 context: List[str] = []
70 empty: bool = True
71 signoff: bool = False
73 # Read commit template
74 with open(filepath, encoding='utf8', mode='r') as file:
75 for line in file:
77 # Get line content
78 content = line.rstrip()
80 # Append context
81 context += [content]
83 # Detect signoff
84 if content and content.startswith(COMMITS_FOOTER_SIGNOFF):
85 signoff = True
87 # Detect contents
88 if content and not any(
89 content.startswith(prefix) for prefix in [
90 COMMITS_COMMENTS_PREFIX,
91 COMMITS_FOOTER_SIGNOFF,
92 ]):
93 empty = False
95 # Detect section
96 if content.startswith(COMMITS_CHANGES_SECTION):
97 capture = True
98 continue
100 # Parse section
101 if capture:
102 matches = match(COMMITS_CHANGES_PATTERN, content)
103 if matches:
104 changes.append(matches.group(3).strip())
105 elif not content.strip() or content.startswith('#'):
106 capture = False
108 # Result
109 return empty, signoff, COMMITS_MESSAGE_EOL.join(context), changes
111# Prepare commit title, pylint: disable=too-many-branches,too-many-locals,too-many-nested-blocks
112def prepare_commit_title(changes: List[str]) -> Tuple[str, str]:
114 # Types
115 Result = NamedTuple('Result', [
116 ('priority', int),
117 ('level', int),
118 ('commit_type', str),
119 ('commit_scope', str),
120 ])
122 # Variables
123 commit_scope: str = COMMITS_DEFAULT_SCOPE
124 commit_type: str = COMMITS_DEFAULT_TYPE
125 level: int
126 levels: List[int]
127 priority: int = 0
128 results: List[Result] = []
130 # Evaluate common changes
131 for changes_evaluator in CHANGES_EVALUATORS:
132 priority += 1
133 for type_change in changes_evaluator.changes:
134 for change in changes:
135 if not search(type_change, change):
136 continue
137 for parser in changes_evaluator.parsers:
138 matches = search(parser.match, change)
139 if not matches or not isinstance(matches.lastindex, int):
140 continue
141 level = 0
142 for group in parser.groups:
143 level += 1
144 if matches.lastindex >= group:
145 results += [
146 Result(
147 priority=priority,
148 level=level,
149 commit_type=parser.commit_type,
150 commit_scope=matches.group(group),
151 )
152 ]
154 # Match common changes
155 for changes_matcher in CHANGES_MATCHERS:
156 priority += 1
157 for matcher_type in changes_matcher.types:
158 for type_change in matcher_type.changes:
159 for change in changes:
160 if search(type_change, change):
161 results += [
162 Result(
163 priority=priority,
164 level=0,
165 commit_type=matcher_type.commit_type,
166 commit_scope=changes_matcher.commit_scope,
167 )
168 ]
170 # Parse results
171 if results:
172 priority = min((result.priority for result in results))
173 results = [result for result in set(results) if result.priority == priority]
174 levels = sorted([result.level for result in results])
175 level = min((level for level in set(levels) if levels.count(level) == 1),
176 default=levels[-1])
177 for result in [result for result in set(results) if result.level == level]:
178 commit_type = result.commit_type
179 commit_scope = result.commit_scope
180 break
182 # Adapt scope
183 commit_scope = commit_scope.lower()
185 # Result
186 return commit_type, commit_scope
188# Prepare commit message, pylint: disable=too-many-arguments,too-many-positional-arguments
189def prepare_commit_template(
190 commit_type: str,
191 commit_scope: str,
192 commit_subject: str,
193 commit_body: str,
194 signoff: bool,
195 always_signoff: bool,
196) -> str:
198 # Variables
199 commit_lines: List[str] = []
201 # Prepare commit title
202 commit_lines += [
203 f'{commit_type}({commit_scope}): {commit_subject}',
204 ]
206 # Prepare commit body
207 template_body = not ([
208 line for line in commit_body.splitlines()
209 if not line.startswith(COMMITS_COMMENTS_PREFIX)
210 ])
211 if commit_body:
212 commit_lines += [
213 '',
214 commit_body,
215 ]
217 # Append commit separator
218 if commit_body and (always_signoff or signoff):
219 commit_lines += [
220 f'{COMMITS_COMMENTS_PREFIX} ---' if template_body else '---',
221 ]
223 # Inject commit signoff
224 if not signoff and always_signoff and 'GIT_AUTHOR_NAME' in environ \
225 and 'GIT_AUTHOR_EMAIL' in environ:
226 commit_lines += [
227 '',
228 f"{COMMITS_FOOTER_SIGNOFF}{environ['GIT_AUTHOR_NAME']} <{environ['GIT_AUTHOR_EMAIL']}>",
229 ]
231 # Append comments separator
232 if not signoff:
233 commit_lines += [
234 '',
235 ]
237 # Result
238 return str(COMMITS_MESSAGE_EOL.join(commit_lines))
240# Main, pylint: disable=too-many-branches,too-many-statements
241def main() -> None:
243 # Variables
244 always_signoff: bool
245 changes: List[str]
246 commit_scope: str
247 commit_type: str
248 context: str
249 empty: bool
250 filepath: str
251 signoff: bool
252 source: Optional[str]
254 # Validate arguments
255 if len(argv) <= 1:
256 sys_exit(1)
258 # Parse arguments
259 filepath = argv[1]
260 source = argv[2] if len(argv) > 2 else None
262 # Ignore commit source, documentation:
263 # - message (if a -m or -F option was given)
264 # - template (if a -t option was given or the configuration option commit.template is set)
265 # - merge (if the commit is a merge or a .git/MERGE_MSG file exists)
266 # - squash (if a .git/SQUASH_MSG file exists)
267 # - commit, followed by a commit object name (if a -c, -C or --amend option was given)
268 if source and source in ['template', 'squash', 'commit']:
269 sys_exit(0)
271 # Parse commitizen configuration
272 always_signoff = parse_commitizen_configurations()
274 # Parse commit input
275 empty, signoff, context, changes = parse_commit_input(filepath)
277 # Existing commit input
278 if not empty:
279 sys_exit(0)
281 # Prepare commit title
282 commit_type, commit_scope = prepare_commit_title(changes)
284 # Prepare commit template
285 commit_template = prepare_commit_template(
286 commit_type=commit_type,
287 commit_scope=commit_scope,
288 commit_subject=COMMITS_DEFAULT_SUBJECT,
289 commit_body=COMMITS_DEFAULT_BODY,
290 signoff=signoff,
291 always_signoff=always_signoff,
292 )
294 # Write commit template
295 if environ.get('GIT_EXEC_PATH', ''):
296 with open(filepath, encoding='utf8', mode='w') as file:
297 file.write(commit_template + context)
299 # Dump commit template
300 else:
301 print(commit_template + context)
303 # Result
304 sys_exit(0)
306# Entrypoint
307if __name__ == '__main__': # pragma: no cover
308 main()