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

1#!/usr/bin/env python3 

2 

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 

10 

11# Modules libraries 

12from yaml import safe_load as yaml_load 

13 

14# Bind sources 

15sys.dont_write_bytecode = True 

16path.append(str(Path(__file__).resolve().parent)) 

17 

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) 

33 

34# Data type 

35Data = Dict[str, Any] 

36 

37# Parse commitizen configuration 

38def parse_commitizen_configurations() -> bool: 

39 

40 # Variables 

41 always_signoff: bool = False 

42 configuration: str 

43 data: Data 

44 

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 

50 

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) 

55 

56 # Extract always_signoff 

57 if 'commitizen' in data and 'always_signoff' in data['commitizen']: 

58 always_signoff = bool(data['commitizen']['always_signoff']) 

59 

60 # Result 

61 return always_signoff 

62 

63# Parse commit input 

64def parse_commit_input(filepath: str) -> Tuple[bool, bool, str, List[str]]: 

65 

66 # Variables 

67 capture: bool = False 

68 changes: List[str] = [] 

69 context: List[str] = [] 

70 empty: bool = True 

71 signoff: bool = False 

72 

73 # Read commit template 

74 with open(filepath, encoding='utf8', mode='r') as file: 

75 for line in file: 

76 

77 # Get line content 

78 content = line.rstrip() 

79 

80 # Append context 

81 context += [content] 

82 

83 # Detect signoff 

84 if content and content.startswith(COMMITS_FOOTER_SIGNOFF): 

85 signoff = True 

86 

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 

94 

95 # Detect section 

96 if content.startswith(COMMITS_CHANGES_SECTION): 

97 capture = True 

98 continue 

99 

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 

107 

108 # Result 

109 return empty, signoff, COMMITS_MESSAGE_EOL.join(context), changes 

110 

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]: 

113 

114 # Types 

115 Result = NamedTuple('Result', [ 

116 ('priority', int), 

117 ('level', int), 

118 ('commit_type', str), 

119 ('commit_scope', str), 

120 ]) 

121 

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] = [] 

129 

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 ] 

153 

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 ] 

169 

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 

181 

182 # Adapt scope 

183 commit_scope = commit_scope.lower() 

184 

185 # Result 

186 return commit_type, commit_scope 

187 

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: 

197 

198 # Variables 

199 commit_lines: List[str] = [] 

200 

201 # Prepare commit title 

202 commit_lines += [ 

203 f'{commit_type}({commit_scope}): {commit_subject}', 

204 ] 

205 

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 ] 

216 

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 ] 

222 

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 ] 

230 

231 # Append comments separator 

232 if not signoff: 

233 commit_lines += [ 

234 '', 

235 ] 

236 

237 # Result 

238 return str(COMMITS_MESSAGE_EOL.join(commit_lines)) 

239 

240# Main, pylint: disable=too-many-branches,too-many-statements 

241def main() -> None: 

242 

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] 

253 

254 # Validate arguments 

255 if len(argv) <= 1: 

256 sys_exit(1) 

257 

258 # Parse arguments 

259 filepath = argv[1] 

260 source = argv[2] if len(argv) > 2 else None 

261 

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) 

270 

271 # Parse commitizen configuration 

272 always_signoff = parse_commitizen_configurations() 

273 

274 # Parse commit input 

275 empty, signoff, context, changes = parse_commit_input(filepath) 

276 

277 # Existing commit input 

278 if not empty: 

279 sys_exit(0) 

280 

281 # Prepare commit title 

282 commit_type, commit_scope = prepare_commit_title(changes) 

283 

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 ) 

293 

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) 

298 

299 # Dump commit template 

300 else: 

301 print(commit_template + context) 

302 

303 # Result 

304 sys_exit(0) 

305 

306# Entrypoint 

307if __name__ == '__main__': # pragma: no cover 

308 main()