1 | ## @file
|
---|
2 | # Check a patch for various format issues
|
---|
3 | #
|
---|
4 | # Copyright (c) 2015 - 2021, Intel Corporation. All rights reserved.<BR>
|
---|
5 | # Copyright (C) 2020, Red Hat, Inc.<BR>
|
---|
6 | # Copyright (c) 2020 - 2023, Arm Limited. All rights reserved.<BR>
|
---|
7 | #
|
---|
8 | # SPDX-License-Identifier: BSD-2-Clause-Patent
|
---|
9 | #
|
---|
10 |
|
---|
11 | from __future__ import print_function
|
---|
12 |
|
---|
13 | VersionNumber = '0.1'
|
---|
14 | __copyright__ = "Copyright (c) 2015 - 2016, Intel Corporation All rights reserved."
|
---|
15 |
|
---|
16 | import email
|
---|
17 | import argparse
|
---|
18 | import os
|
---|
19 | import re
|
---|
20 | import subprocess
|
---|
21 | import sys
|
---|
22 |
|
---|
23 | import email.header
|
---|
24 |
|
---|
25 | class Verbose:
|
---|
26 | SILENT, ONELINE, NORMAL = range(3)
|
---|
27 | level = NORMAL
|
---|
28 |
|
---|
29 | class PatchCheckConf:
|
---|
30 | ignore_change_id = False
|
---|
31 | ignore_multi_package = False
|
---|
32 |
|
---|
33 | class EmailAddressCheck:
|
---|
34 | """Checks an email address."""
|
---|
35 |
|
---|
36 | def __init__(self, email, description):
|
---|
37 | self.ok = True
|
---|
38 |
|
---|
39 | if email is None:
|
---|
40 | self.error('Email address is missing!')
|
---|
41 | return
|
---|
42 | if description is None:
|
---|
43 | self.error('Email description is missing!')
|
---|
44 | return
|
---|
45 |
|
---|
46 | self.description = "'" + description + "'"
|
---|
47 | self.check_email_address(email)
|
---|
48 |
|
---|
49 | def error(self, *err):
|
---|
50 | if self.ok and Verbose.level > Verbose.ONELINE:
|
---|
51 | print('The ' + self.description + ' email address is not valid:')
|
---|
52 | self.ok = False
|
---|
53 | if Verbose.level < Verbose.NORMAL:
|
---|
54 | return
|
---|
55 | count = 0
|
---|
56 | for line in err:
|
---|
57 | prefix = (' *', ' ')[count > 0]
|
---|
58 | print(prefix, line)
|
---|
59 | count += 1
|
---|
60 |
|
---|
61 | email_re1 = re.compile(r'(?:\s*)(.*?)(\s*)<(.+)>\s*$',
|
---|
62 | re.MULTILINE|re.IGNORECASE)
|
---|
63 |
|
---|
64 | def check_email_address(self, email):
|
---|
65 | email = email.strip()
|
---|
66 | mo = self.email_re1.match(email)
|
---|
67 | if mo is None:
|
---|
68 | self.error("Email format is invalid: " + email.strip())
|
---|
69 | return
|
---|
70 |
|
---|
71 | name = mo.group(1).strip()
|
---|
72 | if name == '':
|
---|
73 | self.error("Name is not provided with email address: " +
|
---|
74 | email)
|
---|
75 | else:
|
---|
76 | quoted = len(name) > 2 and name[0] == '"' and name[-1] == '"'
|
---|
77 | if name.find(',') >= 0 and not quoted:
|
---|
78 | self.error('Add quotes (") around name with a comma: ' +
|
---|
79 | name)
|
---|
80 |
|
---|
81 | if mo.group(2) == '':
|
---|
82 | self.error("There should be a space between the name and " +
|
---|
83 | "email address: " + email)
|
---|
84 |
|
---|
85 | if mo.group(3).find(' ') >= 0:
|
---|
86 | self.error("The email address cannot contain a space: " +
|
---|
87 | mo.group(3))
|
---|
88 |
|
---|
89 | if mo.group(3) == '[email protected]':
|
---|
90 | self.error("Email rewritten by lists DMARC / DKIM / SPF: " +
|
---|
91 | email)
|
---|
92 |
|
---|
93 | if ' via groups.io' in name.lower() and mo.group(3).endswith('@groups.io'):
|
---|
94 | self.error("Email rewritten by lists DMARC / DKIM / SPF: " +
|
---|
95 | email)
|
---|
96 |
|
---|
97 | class CommitMessageCheck:
|
---|
98 | """Checks the contents of a git commit message."""
|
---|
99 |
|
---|
100 | def __init__(self, subject, message, author_email):
|
---|
101 | self.ok = True
|
---|
102 | self.ignore_multi_package = False
|
---|
103 |
|
---|
104 | if subject is None and message is None:
|
---|
105 | self.error('Commit message is missing!')
|
---|
106 | return
|
---|
107 |
|
---|
108 | MergifyMerge = False
|
---|
109 | if "mergify[bot]@users.noreply.github.com" in author_email:
|
---|
110 | if "Merge branch" in subject:
|
---|
111 | MergifyMerge = True
|
---|
112 |
|
---|
113 | self.subject = subject
|
---|
114 | self.msg = message
|
---|
115 |
|
---|
116 | print (subject)
|
---|
117 |
|
---|
118 | self.check_contributed_under()
|
---|
119 | if not MergifyMerge:
|
---|
120 | self.check_signed_off_by()
|
---|
121 | self.check_misc_signatures()
|
---|
122 | self.check_overall_format()
|
---|
123 | if not PatchCheckConf.ignore_change_id:
|
---|
124 | self.check_change_id_format()
|
---|
125 | self.check_ci_options_format()
|
---|
126 | self.report_message_result()
|
---|
127 |
|
---|
128 | url = 'https://github.com/tianocore/tianocore.github.io/wiki/Commit-Message-Format'
|
---|
129 |
|
---|
130 | def report_message_result(self):
|
---|
131 | if Verbose.level < Verbose.NORMAL:
|
---|
132 | return
|
---|
133 | if self.ok:
|
---|
134 | # All checks passed
|
---|
135 | return_code = 0
|
---|
136 | print('The commit message format passed all checks.')
|
---|
137 | else:
|
---|
138 | return_code = 1
|
---|
139 | if not self.ok:
|
---|
140 | print(self.url)
|
---|
141 |
|
---|
142 | def error(self, *err):
|
---|
143 | if self.ok and Verbose.level > Verbose.ONELINE:
|
---|
144 | print('The commit message format is not valid:')
|
---|
145 | self.ok = False
|
---|
146 | if Verbose.level < Verbose.NORMAL:
|
---|
147 | return
|
---|
148 | count = 0
|
---|
149 | for line in err:
|
---|
150 | prefix = (' *', ' ')[count > 0]
|
---|
151 | print(prefix, line)
|
---|
152 | count += 1
|
---|
153 |
|
---|
154 | # Find 'contributed-under:' at the start of a line ignoring case and
|
---|
155 | # requires ':' to be present. Matches if there is white space before
|
---|
156 | # the tag or between the tag and the ':'.
|
---|
157 | contributed_under_re = \
|
---|
158 | re.compile(r'^\s*contributed-under\s*:', re.MULTILINE|re.IGNORECASE)
|
---|
159 |
|
---|
160 | def check_contributed_under(self):
|
---|
161 | match = self.contributed_under_re.search(self.msg)
|
---|
162 | if match is not None:
|
---|
163 | self.error('Contributed-under! (Note: this must be ' +
|
---|
164 | 'removed by the code contributor!)')
|
---|
165 |
|
---|
166 | @staticmethod
|
---|
167 | def make_signature_re(sig, re_input=False):
|
---|
168 | if re_input:
|
---|
169 | sub_re = sig
|
---|
170 | else:
|
---|
171 | sub_re = sig.replace('-', r'[-\s]+')
|
---|
172 | re_str = (r'^(?P<tag>' + sub_re +
|
---|
173 | r')(\s*):(\s*)(?P<value>\S.*?)(?:\s*)$')
|
---|
174 | try:
|
---|
175 | return re.compile(re_str, re.MULTILINE|re.IGNORECASE)
|
---|
176 | except Exception:
|
---|
177 | print("Tried to compile re:", re_str)
|
---|
178 | raise
|
---|
179 |
|
---|
180 | sig_block_re = \
|
---|
181 | re.compile(r'''^
|
---|
182 | (?: (?P<tag>[^:]+) \s* : \s*
|
---|
183 | (?P<value>\S.*?) )
|
---|
184 | |
|
---|
185 | (?: \[ (?P<updater>[^:]+) \s* : \s*
|
---|
186 | (?P<note>.+?) \s* \] )
|
---|
187 | \s* $''',
|
---|
188 | re.VERBOSE | re.MULTILINE)
|
---|
189 |
|
---|
190 | def find_signatures(self, sig):
|
---|
191 | if not sig.endswith('-by') and sig != 'Cc':
|
---|
192 | sig += '-by'
|
---|
193 | regex = self.make_signature_re(sig)
|
---|
194 |
|
---|
195 | sigs = regex.findall(self.msg)
|
---|
196 |
|
---|
197 | bad_case_sigs = filter(lambda m: m[0] != sig, sigs)
|
---|
198 | for s in bad_case_sigs:
|
---|
199 | self.error("'" +s[0] + "' should be '" + sig + "'")
|
---|
200 |
|
---|
201 | for s in sigs:
|
---|
202 | if s[1] != '':
|
---|
203 | self.error('There should be no spaces between ' + sig +
|
---|
204 | " and the ':'")
|
---|
205 | if s[2] != ' ':
|
---|
206 | self.error("There should be a space after '" + sig + ":'")
|
---|
207 |
|
---|
208 | self.ok &= EmailAddressCheck(s[3], sig).ok
|
---|
209 |
|
---|
210 | return sigs
|
---|
211 |
|
---|
212 | def check_signed_off_by(self):
|
---|
213 | sob='Signed-off-by'
|
---|
214 | if self.msg.find(sob) < 0:
|
---|
215 | self.error('Missing Signed-off-by! (Note: this must be ' +
|
---|
216 | 'added by the code contributor!)')
|
---|
217 | return
|
---|
218 |
|
---|
219 | sobs = self.find_signatures('Signed-off')
|
---|
220 |
|
---|
221 | if len(sobs) == 0:
|
---|
222 | self.error('Invalid Signed-off-by format!')
|
---|
223 | return
|
---|
224 |
|
---|
225 | sig_types = (
|
---|
226 | 'Reviewed',
|
---|
227 | 'Reported',
|
---|
228 | 'Tested',
|
---|
229 | 'Suggested',
|
---|
230 | 'Acked',
|
---|
231 | 'Cc'
|
---|
232 | )
|
---|
233 |
|
---|
234 | def check_misc_signatures(self):
|
---|
235 | for sigtype in self.sig_types:
|
---|
236 | sigs = self.find_signatures(sigtype)
|
---|
237 | if sigtype == 'Cc' and len(sigs) == 0:
|
---|
238 | self.error('No Cc: tags for maintainers/reviewers found!')
|
---|
239 |
|
---|
240 | cve_re = re.compile('CVE-[0-9]{4}-[0-9]{5}[^0-9]')
|
---|
241 |
|
---|
242 | def check_overall_format(self):
|
---|
243 | lines = self.msg.splitlines()
|
---|
244 |
|
---|
245 | if len(lines) >= 1 and lines[0].endswith('\r\n'):
|
---|
246 | empty_line = '\r\n'
|
---|
247 | else:
|
---|
248 | empty_line = '\n'
|
---|
249 |
|
---|
250 | lines.insert(0, empty_line)
|
---|
251 | lines.insert(0, self.subject + empty_line)
|
---|
252 |
|
---|
253 | count = len(lines)
|
---|
254 |
|
---|
255 | if count <= 0:
|
---|
256 | self.error('Empty commit message!')
|
---|
257 | return
|
---|
258 |
|
---|
259 | if count >= 1 and re.search(self.cve_re, lines[0]):
|
---|
260 | #
|
---|
261 | # If CVE-xxxx-xxxxx is present in subject line, then limit length of
|
---|
262 | # subject line to 92 characters
|
---|
263 | #
|
---|
264 | if len(lines[0].rstrip()) >= 93:
|
---|
265 | self.error(
|
---|
266 | 'First line of commit message (subject line) is too long (%d >= 93).' %
|
---|
267 | (len(lines[0].rstrip()))
|
---|
268 | )
|
---|
269 | else:
|
---|
270 | #
|
---|
271 | # If CVE-xxxx-xxxxx is not present in subject line, then limit
|
---|
272 | # length of subject line to 75 characters
|
---|
273 | #
|
---|
274 | if len(lines[0].rstrip()) >= 76:
|
---|
275 | self.error(
|
---|
276 | 'First line of commit message (subject line) is too long (%d >= 76).' %
|
---|
277 | (len(lines[0].rstrip()))
|
---|
278 | )
|
---|
279 |
|
---|
280 | if count >= 1 and len(lines[0].strip()) == 0:
|
---|
281 | self.error('First line of commit message (subject line) ' +
|
---|
282 | 'is empty.')
|
---|
283 |
|
---|
284 | if count >= 2 and lines[1].strip() != '':
|
---|
285 | self.error('Second line of commit message should be ' +
|
---|
286 | 'empty.')
|
---|
287 |
|
---|
288 | for i in range(2, count):
|
---|
289 | if (len(lines[i]) >= 76 and
|
---|
290 | len(lines[i].split()) > 1 and
|
---|
291 | not lines[i].startswith('git-svn-id:') and
|
---|
292 | not lines[i].startswith('Reviewed-by') and
|
---|
293 | not lines[i].startswith('Acked-by:') and
|
---|
294 | not lines[i].startswith('Tested-by:') and
|
---|
295 | not lines[i].startswith('Reported-by:') and
|
---|
296 | not lines[i].startswith('Suggested-by:') and
|
---|
297 | not lines[i].startswith('Signed-off-by:') and
|
---|
298 | not lines[i].startswith('Cc:')):
|
---|
299 | #
|
---|
300 | # Print a warning if body line is longer than 75 characters
|
---|
301 | #
|
---|
302 | print(
|
---|
303 | 'WARNING - Line %d of commit message is too long (%d >= 76).' %
|
---|
304 | (i + 1, len(lines[i]))
|
---|
305 | )
|
---|
306 | print(lines[i])
|
---|
307 |
|
---|
308 | last_sig_line = None
|
---|
309 | for i in range(count - 1, 0, -1):
|
---|
310 | line = lines[i]
|
---|
311 | mo = self.sig_block_re.match(line)
|
---|
312 | if mo is None:
|
---|
313 | if line.strip() == '':
|
---|
314 | break
|
---|
315 | elif last_sig_line is not None:
|
---|
316 | err2 = 'Add empty line before "%s"?' % last_sig_line
|
---|
317 | self.error('The line before the signature block ' +
|
---|
318 | 'should be empty', err2)
|
---|
319 | else:
|
---|
320 | self.error('The signature block was not found')
|
---|
321 | break
|
---|
322 | last_sig_line = line.strip()
|
---|
323 |
|
---|
324 | def check_change_id_format(self):
|
---|
325 | cid='Change-Id:'
|
---|
326 | if self.msg.find(cid) != -1:
|
---|
327 | self.error('\"%s\" found in commit message:' % cid)
|
---|
328 | return
|
---|
329 |
|
---|
330 | def check_ci_options_format(self):
|
---|
331 | cio='Continuous-integration-options:'
|
---|
332 | for line in self.msg.splitlines():
|
---|
333 | if not line.startswith(cio):
|
---|
334 | continue
|
---|
335 | options = line.split(':', 1)[1].split()
|
---|
336 | if 'PatchCheck.ignore-multi-package' in options:
|
---|
337 | self.ignore_multi_package = True
|
---|
338 |
|
---|
339 | (START, PRE_PATCH, PATCH) = range(3)
|
---|
340 |
|
---|
341 | class GitDiffCheck:
|
---|
342 | """Checks the contents of a git diff."""
|
---|
343 |
|
---|
344 | def __init__(self, diff):
|
---|
345 | self.ok = True
|
---|
346 | self.format_ok = True
|
---|
347 | self.lines = diff.splitlines(True)
|
---|
348 | self.count = len(self.lines)
|
---|
349 | self.line_num = 0
|
---|
350 | self.state = START
|
---|
351 | self.new_bin = []
|
---|
352 | while self.line_num < self.count and self.format_ok:
|
---|
353 | line_num = self.line_num
|
---|
354 | self.run()
|
---|
355 | assert(self.line_num > line_num)
|
---|
356 | self.report_message_result()
|
---|
357 |
|
---|
358 | def report_message_result(self):
|
---|
359 | if Verbose.level < Verbose.NORMAL:
|
---|
360 | return
|
---|
361 | if self.ok:
|
---|
362 | print('The code passed all checks.')
|
---|
363 | if self.new_bin:
|
---|
364 | print('\nWARNING - The following binary files will be added ' +
|
---|
365 | 'into the repository:')
|
---|
366 | for binary in self.new_bin:
|
---|
367 | print(' ' + binary)
|
---|
368 |
|
---|
369 | def run(self):
|
---|
370 | line = self.lines[self.line_num]
|
---|
371 |
|
---|
372 | if self.state in (PRE_PATCH, PATCH):
|
---|
373 | if line.startswith('diff --git'):
|
---|
374 | self.state = START
|
---|
375 | if self.state == PATCH:
|
---|
376 | if line.startswith('@@ '):
|
---|
377 | self.state = PRE_PATCH
|
---|
378 | elif len(line) >= 1 and line[0] not in ' -+' and \
|
---|
379 | not line.startswith('\r\n') and \
|
---|
380 | not line.startswith(r'\ No newline ') and not self.binary:
|
---|
381 | for line in self.lines[self.line_num + 1:]:
|
---|
382 | if line.startswith('diff --git'):
|
---|
383 | self.format_error('diff found after end of patch')
|
---|
384 | break
|
---|
385 | self.line_num = self.count
|
---|
386 | return
|
---|
387 |
|
---|
388 | if self.state == START:
|
---|
389 | if line.startswith('diff --git'):
|
---|
390 | self.state = PRE_PATCH
|
---|
391 | self.filename = line[13:].split(' ', 1)[0]
|
---|
392 | self.is_newfile = False
|
---|
393 | self.force_crlf = True
|
---|
394 | self.force_notabs = True
|
---|
395 | if self.filename.endswith('.rtf'):
|
---|
396 | self.force_crlf = False
|
---|
397 | self.force_notabs = False
|
---|
398 | if self.filename.endswith('.sh') or \
|
---|
399 | self.filename.startswith('BaseTools/BinWrappers/PosixLike/') or \
|
---|
400 | self.filename.startswith('BaseTools/BinPipWrappers/PosixLike/') or \
|
---|
401 | self.filename == 'BaseTools/BuildEnv':
|
---|
402 | #
|
---|
403 | # Do not enforce CR/LF line endings for linux shell scripts.
|
---|
404 | # Some linux shell scripts don't end with the ".sh" extension,
|
---|
405 | # they are identified by their path.
|
---|
406 | #
|
---|
407 | self.force_crlf = False
|
---|
408 | if self.filename == '.gitmodules' or \
|
---|
409 | self.filename == 'BaseTools/Conf/diff.order':
|
---|
410 | #
|
---|
411 | # .gitmodules and diff orderfiles are used internally by git
|
---|
412 | # use tabs and LF line endings. Do not enforce no tabs and
|
---|
413 | # do not enforce CR/LF line endings.
|
---|
414 | #
|
---|
415 | self.force_crlf = False
|
---|
416 | self.force_notabs = False
|
---|
417 | if os.path.basename(self.filename) == 'GNUmakefile' or \
|
---|
418 | os.path.basename(self.filename).lower() == 'makefile' or \
|
---|
419 | os.path.splitext(self.filename)[1] == '.makefile' or \
|
---|
420 | self.filename.startswith(
|
---|
421 | 'BaseTools/Source/C/VfrCompile/Pccts/'):
|
---|
422 | self.force_notabs = False
|
---|
423 | elif len(line.rstrip()) != 0:
|
---|
424 | self.format_error("didn't find diff command")
|
---|
425 | self.line_num += 1
|
---|
426 | elif self.state == PRE_PATCH:
|
---|
427 | if line.startswith('@@ '):
|
---|
428 | self.state = PATCH
|
---|
429 | self.binary = False
|
---|
430 | elif line.startswith('GIT binary patch') or \
|
---|
431 | line.startswith('Binary files'):
|
---|
432 | self.state = PATCH
|
---|
433 | self.binary = True
|
---|
434 | if self.is_newfile:
|
---|
435 | self.new_bin.append(self.filename)
|
---|
436 | elif line.startswith('new file mode 160000'):
|
---|
437 | #
|
---|
438 | # New submodule. Do not enforce CR/LF line endings
|
---|
439 | #
|
---|
440 | self.force_crlf = False
|
---|
441 | else:
|
---|
442 | ok = False
|
---|
443 | self.is_newfile = self.newfile_prefix_re.match(line)
|
---|
444 | for pfx in self.pre_patch_prefixes:
|
---|
445 | if line.startswith(pfx):
|
---|
446 | ok = True
|
---|
447 | if not ok:
|
---|
448 | self.format_error("didn't find diff hunk marker (@@)")
|
---|
449 | self.line_num += 1
|
---|
450 | elif self.state == PATCH:
|
---|
451 | if self.binary or self.filename.endswith(".rtf"):
|
---|
452 | pass
|
---|
453 | elif line.startswith('-'):
|
---|
454 | pass
|
---|
455 | elif line.startswith('+'):
|
---|
456 | self.check_added_line(line[1:])
|
---|
457 | elif line.startswith('\r\n'):
|
---|
458 | pass
|
---|
459 | elif line.startswith(r'\ No newline '):
|
---|
460 | pass
|
---|
461 | elif not line.startswith(' '):
|
---|
462 | self.format_error("unexpected patch line")
|
---|
463 | self.line_num += 1
|
---|
464 |
|
---|
465 | pre_patch_prefixes = (
|
---|
466 | '--- ',
|
---|
467 | '+++ ',
|
---|
468 | 'index ',
|
---|
469 | 'new file ',
|
---|
470 | 'deleted file ',
|
---|
471 | 'old mode ',
|
---|
472 | 'new mode ',
|
---|
473 | 'similarity index ',
|
---|
474 | 'copy from ',
|
---|
475 | 'copy to ',
|
---|
476 | 'rename ',
|
---|
477 | )
|
---|
478 |
|
---|
479 | line_endings = ('\r\n', '\n\r', '\n', '\r')
|
---|
480 |
|
---|
481 | newfile_prefix_re = \
|
---|
482 | re.compile(r'''^
|
---|
483 | index\ 0+\.\.
|
---|
484 | ''',
|
---|
485 | re.VERBOSE)
|
---|
486 |
|
---|
487 | def added_line_error(self, msg, line):
|
---|
488 | lines = [ msg ]
|
---|
489 | if self.filename is not None:
|
---|
490 | lines.append('File: ' + self.filename)
|
---|
491 | lines.append('Line: ' + line)
|
---|
492 |
|
---|
493 | self.error(*lines)
|
---|
494 |
|
---|
495 | old_debug_re = \
|
---|
496 | re.compile(r'''
|
---|
497 | DEBUG \s* \( \s* \( \s*
|
---|
498 | (?: DEBUG_[A-Z_]+ \s* \| \s*)*
|
---|
499 | EFI_D_ ([A-Z_]+)
|
---|
500 | ''',
|
---|
501 | re.VERBOSE)
|
---|
502 |
|
---|
503 | def check_added_line(self, line):
|
---|
504 | eol = ''
|
---|
505 | for an_eol in self.line_endings:
|
---|
506 | if line.endswith(an_eol):
|
---|
507 | eol = an_eol
|
---|
508 | line = line[:-len(eol)]
|
---|
509 |
|
---|
510 | stripped = line.rstrip()
|
---|
511 |
|
---|
512 | if self.force_crlf and eol != '\r\n' and (line.find('Subproject commit') == -1):
|
---|
513 | self.added_line_error('Line ending (%s) is not CRLF' % repr(eol),
|
---|
514 | line)
|
---|
515 | if self.force_notabs and '\t' in line:
|
---|
516 | self.added_line_error('Tab character used', line)
|
---|
517 | if len(stripped) < len(line):
|
---|
518 | self.added_line_error('Trailing whitespace found', line)
|
---|
519 |
|
---|
520 | mo = self.old_debug_re.search(line)
|
---|
521 | if mo is not None:
|
---|
522 | self.added_line_error('EFI_D_' + mo.group(1) + ' was used, '
|
---|
523 | 'but DEBUG_' + mo.group(1) +
|
---|
524 | ' is now recommended', line)
|
---|
525 |
|
---|
526 | rp_file = os.path.realpath(self.filename)
|
---|
527 | rp_script = os.path.realpath(__file__)
|
---|
528 | if line.find('__FUNCTION__') != -1 and rp_file != rp_script:
|
---|
529 | self.added_line_error('__FUNCTION__ was used, but __func__ '
|
---|
530 | 'is now recommended', line)
|
---|
531 |
|
---|
532 | split_diff_re = re.compile(r'''
|
---|
533 | (?P<cmd>
|
---|
534 | ^ diff \s+ --git \s+ a/.+ \s+ b/.+ $
|
---|
535 | )
|
---|
536 | (?P<index>
|
---|
537 | ^ index \s+ .+ $
|
---|
538 | )
|
---|
539 | ''',
|
---|
540 | re.IGNORECASE | re.VERBOSE | re.MULTILINE)
|
---|
541 |
|
---|
542 | def format_error(self, err):
|
---|
543 | self.format_ok = False
|
---|
544 | err = 'Patch format error: ' + err
|
---|
545 | err2 = 'Line: ' + self.lines[self.line_num].rstrip()
|
---|
546 | self.error(err, err2)
|
---|
547 |
|
---|
548 | def error(self, *err):
|
---|
549 | if self.ok and Verbose.level > Verbose.ONELINE:
|
---|
550 | print('Code format is not valid:')
|
---|
551 | self.ok = False
|
---|
552 | if Verbose.level < Verbose.NORMAL:
|
---|
553 | return
|
---|
554 | count = 0
|
---|
555 | for line in err:
|
---|
556 | prefix = (' *', ' ')[count > 0]
|
---|
557 | print(prefix, line)
|
---|
558 | count += 1
|
---|
559 |
|
---|
560 | class CheckOnePatch:
|
---|
561 | """Checks the contents of a git email formatted patch.
|
---|
562 |
|
---|
563 | Various checks are performed on both the commit message and the
|
---|
564 | patch content.
|
---|
565 | """
|
---|
566 |
|
---|
567 | def __init__(self, name, patch):
|
---|
568 | self.patch = patch
|
---|
569 | self.find_patch_pieces()
|
---|
570 |
|
---|
571 | email_check = EmailAddressCheck(self.author_email, 'Author')
|
---|
572 | email_ok = email_check.ok
|
---|
573 |
|
---|
574 | msg_check = CommitMessageCheck(self.commit_subject, self.commit_msg, self.author_email)
|
---|
575 | msg_ok = msg_check.ok
|
---|
576 | self.ignore_multi_package = msg_check.ignore_multi_package
|
---|
577 |
|
---|
578 | diff_ok = True
|
---|
579 | if self.diff is not None:
|
---|
580 | diff_check = GitDiffCheck(self.diff)
|
---|
581 | diff_ok = diff_check.ok
|
---|
582 |
|
---|
583 | self.ok = email_ok and msg_ok and diff_ok
|
---|
584 |
|
---|
585 | if Verbose.level == Verbose.ONELINE:
|
---|
586 | if self.ok:
|
---|
587 | result = 'ok'
|
---|
588 | else:
|
---|
589 | result = list()
|
---|
590 | if not msg_ok:
|
---|
591 | result.append('commit message')
|
---|
592 | if not diff_ok:
|
---|
593 | result.append('diff content')
|
---|
594 | result = 'bad ' + ' and '.join(result)
|
---|
595 | print(name, result)
|
---|
596 |
|
---|
597 |
|
---|
598 | git_diff_re = re.compile(r'''
|
---|
599 | ^ diff \s+ --git \s+ a/.+ \s+ b/.+ $
|
---|
600 | ''',
|
---|
601 | re.IGNORECASE | re.VERBOSE | re.MULTILINE)
|
---|
602 |
|
---|
603 | stat_re = \
|
---|
604 | re.compile(r'''
|
---|
605 | (?P<commit_message> [\s\S\r\n]* )
|
---|
606 | (?P<stat>
|
---|
607 | ^ --- $ [\r\n]+
|
---|
608 | (?: ^ \s+ .+ \s+ \| \s+ \d+ \s+ \+* \-*
|
---|
609 | $ [\r\n]+ )+
|
---|
610 | [\s\S\r\n]+
|
---|
611 | )
|
---|
612 | ''',
|
---|
613 | re.IGNORECASE | re.VERBOSE | re.MULTILINE)
|
---|
614 |
|
---|
615 | subject_prefix_re = \
|
---|
616 | re.compile(r'''^
|
---|
617 | \s* (\[
|
---|
618 | [^\[\]]* # Allow all non-brackets
|
---|
619 | \])* \s*
|
---|
620 | ''',
|
---|
621 | re.VERBOSE)
|
---|
622 |
|
---|
623 | def find_patch_pieces(self):
|
---|
624 | if sys.version_info < (3, 0):
|
---|
625 | patch = self.patch.encode('ascii', 'ignore')
|
---|
626 | else:
|
---|
627 | patch = self.patch
|
---|
628 |
|
---|
629 | self.commit_msg = None
|
---|
630 | self.stat = None
|
---|
631 | self.commit_subject = None
|
---|
632 | self.commit_prefix = None
|
---|
633 | self.diff = None
|
---|
634 |
|
---|
635 | if patch.startswith('diff --git'):
|
---|
636 | self.diff = patch
|
---|
637 | return
|
---|
638 |
|
---|
639 | pmail = email.message_from_string(patch)
|
---|
640 | parts = list(pmail.walk())
|
---|
641 | assert(len(parts) == 1)
|
---|
642 | assert(parts[0].get_content_type() == 'text/plain')
|
---|
643 | content = parts[0].get_payload(decode=True).decode('utf-8', 'ignore')
|
---|
644 |
|
---|
645 | mo = self.git_diff_re.search(content)
|
---|
646 | if mo is not None:
|
---|
647 | self.diff = content[mo.start():]
|
---|
648 | content = content[:mo.start()]
|
---|
649 |
|
---|
650 | mo = self.stat_re.search(content)
|
---|
651 | if mo is None:
|
---|
652 | self.commit_msg = content
|
---|
653 | else:
|
---|
654 | self.stat = mo.group('stat')
|
---|
655 | self.commit_msg = mo.group('commit_message')
|
---|
656 | #
|
---|
657 | # Parse subject line from email header. The subject line may be
|
---|
658 | # composed of multiple parts with different encodings. Decode and
|
---|
659 | # combine all the parts to produce a single string with the contents of
|
---|
660 | # the decoded subject line.
|
---|
661 | #
|
---|
662 | parts = email.header.decode_header(pmail.get('subject'))
|
---|
663 | subject = ''
|
---|
664 | for (part, encoding) in parts:
|
---|
665 | if encoding:
|
---|
666 | part = part.decode(encoding)
|
---|
667 | else:
|
---|
668 | try:
|
---|
669 | part = part.decode()
|
---|
670 | except:
|
---|
671 | pass
|
---|
672 | subject = subject + part
|
---|
673 |
|
---|
674 | self.commit_subject = subject.replace('\r\n', '')
|
---|
675 | self.commit_subject = self.commit_subject.replace('\n', '')
|
---|
676 | self.commit_subject = self.subject_prefix_re.sub('', self.commit_subject, 1)
|
---|
677 |
|
---|
678 | self.author_email = pmail['from']
|
---|
679 |
|
---|
680 | class CheckGitCommits:
|
---|
681 | """Reads patches from git based on the specified git revision range.
|
---|
682 |
|
---|
683 | The patches are read from git, and then checked.
|
---|
684 | """
|
---|
685 |
|
---|
686 | def __init__(self, rev_spec, max_count):
|
---|
687 | dec_files = self.read_dec_files_from_git()
|
---|
688 | commits = self.read_commit_list_from_git(rev_spec, max_count)
|
---|
689 | if len(commits) == 1 and Verbose.level > Verbose.ONELINE:
|
---|
690 | commits = [ rev_spec ]
|
---|
691 | self.ok = True
|
---|
692 | blank_line = False
|
---|
693 | for commit in commits:
|
---|
694 | if Verbose.level > Verbose.ONELINE:
|
---|
695 | if blank_line:
|
---|
696 | print()
|
---|
697 | else:
|
---|
698 | blank_line = True
|
---|
699 | print('Checking git commit:', commit)
|
---|
700 | email = self.read_committer_email_address_from_git(commit)
|
---|
701 | self.ok &= EmailAddressCheck(email, 'Committer').ok
|
---|
702 | patch = self.read_patch_from_git(commit)
|
---|
703 | check_patch = CheckOnePatch(commit, patch)
|
---|
704 | self.ok &= check_patch.ok
|
---|
705 | ignore_multi_package = check_patch.ignore_multi_package
|
---|
706 | if PatchCheckConf.ignore_multi_package:
|
---|
707 | ignore_multi_package = True
|
---|
708 | prefix = 'WARNING: ' if ignore_multi_package else ''
|
---|
709 | check_parent = self.check_parent_packages (dec_files, commit, prefix)
|
---|
710 | if not ignore_multi_package:
|
---|
711 | self.ok &= check_parent
|
---|
712 |
|
---|
713 | if not commits:
|
---|
714 | print("Couldn't find commit matching: '{}'".format(rev_spec))
|
---|
715 |
|
---|
716 | def check_parent_packages(self, dec_files, commit, prefix):
|
---|
717 | ok = True
|
---|
718 | modified = self.get_parent_packages (dec_files, commit, 'AM')
|
---|
719 | if len (modified) > 1:
|
---|
720 | print("{}The commit adds/modifies files in multiple packages:".format(prefix))
|
---|
721 | print(" *", '\n * '.join(modified))
|
---|
722 | ok = False
|
---|
723 | deleted = self.get_parent_packages (dec_files, commit, 'D')
|
---|
724 | if len (deleted) > 1:
|
---|
725 | print("{}The commit deletes files from multiple packages:".format(prefix))
|
---|
726 | print(" *", '\n * '.join(deleted))
|
---|
727 | ok = False
|
---|
728 | return ok
|
---|
729 |
|
---|
730 | def get_parent_packages(self, dec_files, commit, filter):
|
---|
731 | filelist = self.read_files_modified_from_git (commit, filter)
|
---|
732 | parents = set()
|
---|
733 | for file in filelist:
|
---|
734 | dec_found = False
|
---|
735 | for dec_file in dec_files:
|
---|
736 | if os.path.commonpath([dec_file, file]):
|
---|
737 | dec_found = True
|
---|
738 | parents.add(dec_file)
|
---|
739 | if not dec_found and os.path.dirname (file):
|
---|
740 | # No DEC file found and file is in a subdir
|
---|
741 | # Covers BaseTools, .github, .azurepipelines, .pytool
|
---|
742 | parents.add(file.split('/')[0])
|
---|
743 | return list(parents)
|
---|
744 |
|
---|
745 | def read_dec_files_from_git(self):
|
---|
746 | # run git ls-files *.dec
|
---|
747 | out = self.run_git('ls-files', '*.dec')
|
---|
748 | # return list of .dec files
|
---|
749 | try:
|
---|
750 | return out.split()
|
---|
751 | except:
|
---|
752 | return []
|
---|
753 |
|
---|
754 | def read_files_modified_from_git(self, commit, filter):
|
---|
755 | # run git diff-tree --no-commit-id --name-only -r <commit>
|
---|
756 | out = self.run_git('diff-tree', '--no-commit-id', '--name-only',
|
---|
757 | '--diff-filter=' + filter, '-r', commit)
|
---|
758 | try:
|
---|
759 | return out.split()
|
---|
760 | except:
|
---|
761 | return []
|
---|
762 |
|
---|
763 | def read_commit_list_from_git(self, rev_spec, max_count):
|
---|
764 | # Run git to get the commit patch
|
---|
765 | cmd = [ 'rev-list', '--abbrev-commit', '--no-walk' ]
|
---|
766 | if max_count is not None:
|
---|
767 | cmd.append('--max-count=' + str(max_count))
|
---|
768 | cmd.append(rev_spec)
|
---|
769 | out = self.run_git(*cmd)
|
---|
770 | return out.split() if out else []
|
---|
771 |
|
---|
772 | def read_patch_from_git(self, commit):
|
---|
773 | # Run git to get the commit patch
|
---|
774 | return self.run_git('show', '--pretty=email', '--no-textconv',
|
---|
775 | '--no-use-mailmap', commit)
|
---|
776 |
|
---|
777 | def read_committer_email_address_from_git(self, commit):
|
---|
778 | # Run git to get the committer email
|
---|
779 | return self.run_git('show', '--pretty=%cn <%ce>', '--no-patch',
|
---|
780 | '--no-use-mailmap', commit)
|
---|
781 |
|
---|
782 | def run_git(self, *args):
|
---|
783 | cmd = [ 'git' ]
|
---|
784 | cmd += args
|
---|
785 | p = subprocess.Popen(cmd,
|
---|
786 | stdout=subprocess.PIPE,
|
---|
787 | stderr=subprocess.STDOUT)
|
---|
788 | Result = p.communicate()
|
---|
789 | return Result[0].decode('utf-8', 'ignore') if Result[0] and Result[0].find(b"fatal")!=0 else None
|
---|
790 |
|
---|
791 | class CheckOnePatchFile:
|
---|
792 | """Performs a patch check for a single file.
|
---|
793 |
|
---|
794 | stdin is used when the filename is '-'.
|
---|
795 | """
|
---|
796 |
|
---|
797 | def __init__(self, patch_filename):
|
---|
798 | if patch_filename == '-':
|
---|
799 | patch = sys.stdin.read()
|
---|
800 | patch_filename = 'stdin'
|
---|
801 | else:
|
---|
802 | f = open(patch_filename, 'rb')
|
---|
803 | patch = f.read().decode('utf-8', 'ignore')
|
---|
804 | f.close()
|
---|
805 | if Verbose.level > Verbose.ONELINE:
|
---|
806 | print('Checking patch file:', patch_filename)
|
---|
807 | self.ok = CheckOnePatch(patch_filename, patch).ok
|
---|
808 |
|
---|
809 | class CheckOneArg:
|
---|
810 | """Performs a patch check for a single command line argument.
|
---|
811 |
|
---|
812 | The argument will be handed off to a file or git-commit based
|
---|
813 | checker.
|
---|
814 | """
|
---|
815 |
|
---|
816 | def __init__(self, param, max_count=None):
|
---|
817 | self.ok = True
|
---|
818 | if param == '-' or os.path.exists(param):
|
---|
819 | checker = CheckOnePatchFile(param)
|
---|
820 | else:
|
---|
821 | checker = CheckGitCommits(param, max_count)
|
---|
822 | self.ok = checker.ok
|
---|
823 |
|
---|
824 | class PatchCheckApp:
|
---|
825 | """Checks patches based on the command line arguments."""
|
---|
826 |
|
---|
827 | def __init__(self):
|
---|
828 | self.parse_options()
|
---|
829 | patches = self.args.patches
|
---|
830 |
|
---|
831 | if len(patches) == 0:
|
---|
832 | patches = [ 'HEAD' ]
|
---|
833 |
|
---|
834 | self.ok = True
|
---|
835 | self.count = None
|
---|
836 | for patch in patches:
|
---|
837 | self.process_one_arg(patch)
|
---|
838 |
|
---|
839 | if self.count is not None:
|
---|
840 | self.process_one_arg('HEAD')
|
---|
841 |
|
---|
842 | if self.ok:
|
---|
843 | self.retval = 0
|
---|
844 | else:
|
---|
845 | self.retval = -1
|
---|
846 |
|
---|
847 | def process_one_arg(self, arg):
|
---|
848 | if len(arg) >= 2 and arg[0] == '-':
|
---|
849 | try:
|
---|
850 | self.count = int(arg[1:])
|
---|
851 | return
|
---|
852 | except ValueError:
|
---|
853 | pass
|
---|
854 | self.ok &= CheckOneArg(arg, self.count).ok
|
---|
855 | self.count = None
|
---|
856 |
|
---|
857 | def parse_options(self):
|
---|
858 | parser = argparse.ArgumentParser(description=__copyright__)
|
---|
859 | parser.add_argument('--version', action='version',
|
---|
860 | version='%(prog)s ' + VersionNumber)
|
---|
861 | parser.add_argument('patches', nargs='*',
|
---|
862 | help='[patch file | git rev list]')
|
---|
863 | group = parser.add_mutually_exclusive_group()
|
---|
864 | group.add_argument("--oneline",
|
---|
865 | action="store_true",
|
---|
866 | help="Print one result per line")
|
---|
867 | group.add_argument("--silent",
|
---|
868 | action="store_true",
|
---|
869 | help="Print nothing")
|
---|
870 | group.add_argument("--ignore-change-id",
|
---|
871 | action="store_true",
|
---|
872 | help="Ignore the presence of 'Change-Id:' tags in commit message")
|
---|
873 | group.add_argument("--ignore-multi-package",
|
---|
874 | action="store_true",
|
---|
875 | help="Ignore if commit modifies files in multiple packages")
|
---|
876 | self.args = parser.parse_args()
|
---|
877 | if self.args.oneline:
|
---|
878 | Verbose.level = Verbose.ONELINE
|
---|
879 | if self.args.silent:
|
---|
880 | Verbose.level = Verbose.SILENT
|
---|
881 | if self.args.ignore_change_id:
|
---|
882 | PatchCheckConf.ignore_change_id = True
|
---|
883 | if self.args.ignore_multi_package:
|
---|
884 | PatchCheckConf.ignore_multi_package = True
|
---|
885 |
|
---|
886 | if __name__ == "__main__":
|
---|
887 | sys.exit(PatchCheckApp().retval)
|
---|