]> git.proxmox.com Git - mirror_edk2.git/blame - BaseTools/Scripts/PatchCheck.py
BaseTools: Replace BSD License with BSD+Patent License
[mirror_edk2.git] / BaseTools / Scripts / PatchCheck.py
CommitLineData
a7e173b0
JJ
1## @file\r
2# Check a patch for various format issues\r
3#\r
5ac4548c 4# Copyright (c) 2015 - 2018, Intel Corporation. All rights reserved.<BR>\r
a7e173b0 5#\r
2e351cbe 6# SPDX-License-Identifier: BSD-2-Clause-Patent\r
a7e173b0
JJ
7#\r
8\r
9from __future__ import print_function\r
10\r
11VersionNumber = '0.1'\r
e6140670 12__copyright__ = "Copyright (c) 2015 - 2016, Intel Corporation All rights reserved."\r
a7e173b0
JJ
13\r
14import email\r
15import argparse\r
16import os\r
17import re\r
18import subprocess\r
19import sys\r
20\r
21class Verbose:\r
22 SILENT, ONELINE, NORMAL = range(3)\r
23 level = NORMAL\r
24\r
25class CommitMessageCheck:\r
26 """Checks the contents of a git commit message."""\r
27\r
28 def __init__(self, subject, message):\r
29 self.ok = True\r
30\r
31 if subject is None and message is None:\r
32 self.error('Commit message is missing!')\r
33 return\r
34\r
35 self.subject = subject\r
36 self.msg = message\r
37\r
38 self.check_contributed_under()\r
39 self.check_signed_off_by()\r
40 self.check_misc_signatures()\r
41 self.check_overall_format()\r
42 self.report_message_result()\r
43\r
44 url = 'https://github.com/tianocore/tianocore.github.io/wiki/Commit-Message-Format'\r
45\r
46 def report_message_result(self):\r
47 if Verbose.level < Verbose.NORMAL:\r
48 return\r
49 if self.ok:\r
50 # All checks passed\r
51 return_code = 0\r
52 print('The commit message format passed all checks.')\r
53 else:\r
54 return_code = 1\r
55 if not self.ok:\r
56 print(self.url)\r
57\r
58 def error(self, *err):\r
59 if self.ok and Verbose.level > Verbose.ONELINE:\r
60 print('The commit message format is not valid:')\r
61 self.ok = False\r
62 if Verbose.level < Verbose.NORMAL:\r
63 return\r
64 count = 0\r
65 for line in err:\r
66 prefix = (' *', ' ')[count > 0]\r
67 print(prefix, line)\r
68 count += 1\r
69\r
70 def check_contributed_under(self):\r
f2d1b866 71 cu_msg='Contributed-under: TianoCore Contribution Agreement 1.1'\r
a7e173b0 72 if self.msg.find(cu_msg) < 0:\r
f2d1b866
MK
73 # Allow 1.0 for now while EDK II community transitions to 1.1\r
74 cu_msg='Contributed-under: TianoCore Contribution Agreement 1.0'\r
75 if self.msg.find(cu_msg) < 0:\r
76 self.error('Missing Contributed-under! (Note: this must be ' +\r
77 'added by the code contributor!)')\r
a7e173b0
JJ
78\r
79 @staticmethod\r
80 def make_signature_re(sig, re_input=False):\r
81 if re_input:\r
82 sub_re = sig\r
83 else:\r
84 sub_re = sig.replace('-', r'[-\s]+')\r
85 re_str = (r'^(?P<tag>' + sub_re +\r
86 r')(\s*):(\s*)(?P<value>\S.*?)(?:\s*)$')\r
87 try:\r
88 return re.compile(re_str, re.MULTILINE|re.IGNORECASE)\r
89 except Exception:\r
90 print("Tried to compile re:", re_str)\r
91 raise\r
92\r
93 sig_block_re = \\r
94 re.compile(r'''^\r
95 (?: (?P<tag>[^:]+) \s* : \s*\r
96 (?P<value>\S.*?) )\r
97 |\r
98 (?: \[ (?P<updater>[^:]+) \s* : \s*\r
99 (?P<note>.+?) \s* \] )\r
100 \s* $''',\r
101 re.VERBOSE | re.MULTILINE)\r
102\r
103 def find_signatures(self, sig):\r
104 if not sig.endswith('-by') and sig != 'Cc':\r
105 sig += '-by'\r
106 regex = self.make_signature_re(sig)\r
107\r
108 sigs = regex.findall(self.msg)\r
109\r
110 bad_case_sigs = filter(lambda m: m[0] != sig, sigs)\r
111 for s in bad_case_sigs:\r
112 self.error("'" +s[0] + "' should be '" + sig + "'")\r
113\r
114 for s in sigs:\r
115 if s[1] != '':\r
116 self.error('There should be no spaces between ' + sig +\r
117 " and the ':'")\r
118 if s[2] != ' ':\r
119 self.error("There should be a space after '" + sig + ":'")\r
120\r
121 self.check_email_address(s[3])\r
122\r
123 return sigs\r
124\r
125 email_re1 = re.compile(r'(?:\s*)(.*?)(\s*)<(.+)>\s*$',\r
126 re.MULTILINE|re.IGNORECASE)\r
127\r
128 def check_email_address(self, email):\r
129 email = email.strip()\r
130 mo = self.email_re1.match(email)\r
131 if mo is None:\r
132 self.error("Email format is invalid: " + email.strip())\r
133 return\r
134\r
135 name = mo.group(1).strip()\r
136 if name == '':\r
137 self.error("Name is not provided with email address: " +\r
138 email)\r
139 else:\r
140 quoted = len(name) > 2 and name[0] == '"' and name[-1] == '"'\r
141 if name.find(',') >= 0 and not quoted:\r
142 self.error('Add quotes (") around name with a comma: ' +\r
143 name)\r
144\r
145 if mo.group(2) == '':\r
146 self.error("There should be a space between the name and " +\r
147 "email address: " + email)\r
148\r
149 if mo.group(3).find(' ') >= 0:\r
150 self.error("The email address cannot contain a space: " +\r
151 mo.group(3))\r
152\r
153 def check_signed_off_by(self):\r
154 sob='Signed-off-by'\r
155 if self.msg.find(sob) < 0:\r
156 self.error('Missing Signed-off-by! (Note: this must be ' +\r
157 'added by the code contributor!)')\r
158 return\r
159\r
160 sobs = self.find_signatures('Signed-off')\r
161\r
162 if len(sobs) == 0:\r
163 self.error('Invalid Signed-off-by format!')\r
164 return\r
165\r
166 sig_types = (\r
167 'Reviewed',\r
168 'Reported',\r
169 'Tested',\r
170 'Suggested',\r
171 'Acked',\r
172 'Cc'\r
173 )\r
174\r
175 def check_misc_signatures(self):\r
176 for sig in self.sig_types:\r
177 self.find_signatures(sig)\r
178\r
179 def check_overall_format(self):\r
180 lines = self.msg.splitlines()\r
181\r
182 if len(lines) >= 1 and lines[0].endswith('\r\n'):\r
183 empty_line = '\r\n'\r
184 else:\r
185 empty_line = '\n'\r
186\r
187 lines.insert(0, empty_line)\r
188 lines.insert(0, self.subject + empty_line)\r
189\r
190 count = len(lines)\r
191\r
192 if count <= 0:\r
193 self.error('Empty commit message!')\r
194 return\r
195\r
e6140670 196 if count >= 1 and len(lines[0]) >= 72:\r
a7e173b0
JJ
197 self.error('First line of commit message (subject line) ' +\r
198 'is too long.')\r
199\r
200 if count >= 1 and len(lines[0].strip()) == 0:\r
201 self.error('First line of commit message (subject line) ' +\r
202 'is empty.')\r
203\r
204 if count >= 2 and lines[1].strip() != '':\r
205 self.error('Second line of commit message should be ' +\r
206 'empty.')\r
207\r
208 for i in range(2, count):\r
e6140670 209 if (len(lines[i]) >= 76 and\r
a7e173b0
JJ
210 len(lines[i].split()) > 1 and\r
211 not lines[i].startswith('git-svn-id:')):\r
212 self.error('Line %d of commit message is too long.' % (i + 1))\r
213\r
214 last_sig_line = None\r
215 for i in range(count - 1, 0, -1):\r
216 line = lines[i]\r
217 mo = self.sig_block_re.match(line)\r
218 if mo is None:\r
219 if line.strip() == '':\r
220 break\r
221 elif last_sig_line is not None:\r
222 err2 = 'Add empty line before "%s"?' % last_sig_line\r
223 self.error('The line before the signature block ' +\r
224 'should be empty', err2)\r
225 else:\r
226 self.error('The signature block was not found')\r
227 break\r
228 last_sig_line = line.strip()\r
229\r
230(START, PRE_PATCH, PATCH) = range(3)\r
231\r
232class GitDiffCheck:\r
233 """Checks the contents of a git diff."""\r
234\r
235 def __init__(self, diff):\r
236 self.ok = True\r
237 self.format_ok = True\r
238 self.lines = diff.splitlines(True)\r
239 self.count = len(self.lines)\r
240 self.line_num = 0\r
241 self.state = START\r
6a69dd49 242 self.new_bin = []\r
a7e173b0
JJ
243 while self.line_num < self.count and self.format_ok:\r
244 line_num = self.line_num\r
245 self.run()\r
246 assert(self.line_num > line_num)\r
247 self.report_message_result()\r
248\r
249 def report_message_result(self):\r
250 if Verbose.level < Verbose.NORMAL:\r
251 return\r
252 if self.ok:\r
253 print('The code passed all checks.')\r
6a69dd49
HW
254 if self.new_bin:\r
255 print('\nWARNING - The following binary files will be added ' +\r
256 'into the repository:')\r
257 for binary in self.new_bin:\r
258 print(' ' + binary)\r
a7e173b0
JJ
259\r
260 def run(self):\r
261 line = self.lines[self.line_num]\r
262\r
263 if self.state in (PRE_PATCH, PATCH):\r
264 if line.startswith('diff --git'):\r
265 self.state = START\r
266 if self.state == PATCH:\r
267 if line.startswith('@@ '):\r
268 self.state = PRE_PATCH\r
269 elif len(line) >= 1 and line[0] not in ' -+' and \\r
aab57eff 270 not line.startswith(r'\ No newline ') and not self.binary:\r
a7e173b0
JJ
271 for line in self.lines[self.line_num + 1:]:\r
272 if line.startswith('diff --git'):\r
273 self.format_error('diff found after end of patch')\r
274 break\r
275 self.line_num = self.count\r
276 return\r
277\r
278 if self.state == START:\r
279 if line.startswith('diff --git'):\r
280 self.state = PRE_PATCH\r
ccaa7754 281 self.filename = line[13:].split(' ', 1)[0]\r
6a69dd49
HW
282 self.is_newfile = False\r
283 self.force_crlf = not self.filename.endswith('.sh')\r
a7e173b0
JJ
284 elif len(line.rstrip()) != 0:\r
285 self.format_error("didn't find diff command")\r
286 self.line_num += 1\r
287 elif self.state == PRE_PATCH:\r
a7e173b0
JJ
288 if line.startswith('@@ '):\r
289 self.state = PATCH\r
103733f8 290 self.binary = False\r
6a69dd49
HW
291 elif line.startswith('GIT binary patch') or \\r
292 line.startswith('Binary files'):\r
103733f8
JJ
293 self.state = PATCH\r
294 self.binary = True\r
6a69dd49
HW
295 if self.is_newfile:\r
296 self.new_bin.append(self.filename)\r
a7e173b0
JJ
297 else:\r
298 ok = False\r
6a69dd49 299 self.is_newfile = self.newfile_prefix_re.match(line)\r
a7e173b0
JJ
300 for pfx in self.pre_patch_prefixes:\r
301 if line.startswith(pfx):\r
302 ok = True\r
303 if not ok:\r
304 self.format_error("didn't find diff hunk marker (@@)")\r
305 self.line_num += 1\r
306 elif self.state == PATCH:\r
103733f8
JJ
307 if self.binary:\r
308 pass\r
aab57eff 309 elif line.startswith('-'):\r
a7e173b0
JJ
310 pass\r
311 elif line.startswith('+'):\r
312 self.check_added_line(line[1:])\r
313 elif line.startswith(r'\ No newline '):\r
314 pass\r
315 elif not line.startswith(' '):\r
316 self.format_error("unexpected patch line")\r
317 self.line_num += 1\r
318\r
319 pre_patch_prefixes = (\r
320 '--- ',\r
321 '+++ ',\r
322 'index ',\r
323 'new file ',\r
324 'deleted file ',\r
325 'old mode ',\r
326 'new mode ',\r
327 'similarity index ',\r
328 'rename ',\r
a7e173b0
JJ
329 )\r
330\r
331 line_endings = ('\r\n', '\n\r', '\n', '\r')\r
332\r
6a69dd49
HW
333 newfile_prefix_re = \\r
334 re.compile(r'''^\r
335 index\ 0+\.\.\r
336 ''',\r
337 re.VERBOSE)\r
a7e173b0
JJ
338\r
339 def added_line_error(self, msg, line):\r
340 lines = [ msg ]\r
6a69dd49
HW
341 if self.filename is not None:\r
342 lines.append('File: ' + self.filename)\r
a7e173b0
JJ
343 lines.append('Line: ' + line)\r
344\r
345 self.error(*lines)\r
346\r
c3926cdb
YZ
347 old_debug_re = \\r
348 re.compile(r'''\r
349 DEBUG \s* \( \s* \( \s*\r
350 (?: DEBUG_[A-Z_]+ \s* \| \s*)*\r
351 EFI_D_ ([A-Z_]+)\r
352 ''',\r
353 re.VERBOSE)\r
354\r
a7e173b0
JJ
355 def check_added_line(self, line):\r
356 eol = ''\r
357 for an_eol in self.line_endings:\r
358 if line.endswith(an_eol):\r
359 eol = an_eol\r
360 line = line[:-len(eol)]\r
361\r
362 stripped = line.rstrip()\r
363\r
364 if self.force_crlf and eol != '\r\n':\r
365 self.added_line_error('Line ending (%s) is not CRLF' % repr(eol),\r
366 line)\r
367 if '\t' in line:\r
368 self.added_line_error('Tab character used', line)\r
369 if len(stripped) < len(line):\r
370 self.added_line_error('Trailing whitespace found', line)\r
371\r
c3926cdb
YZ
372 mo = self.old_debug_re.search(line)\r
373 if mo is not None:\r
374 self.added_line_error('EFI_D_' + mo.group(1) + ' was used, '\r
375 'but DEBUG_' + mo.group(1) +\r
376 ' is now recommended', line)\r
377\r
a7e173b0
JJ
378 split_diff_re = re.compile(r'''\r
379 (?P<cmd>\r
380 ^ diff \s+ --git \s+ a/.+ \s+ b/.+ $\r
381 )\r
382 (?P<index>\r
383 ^ index \s+ .+ $\r
384 )\r
385 ''',\r
386 re.IGNORECASE | re.VERBOSE | re.MULTILINE)\r
387\r
388 def format_error(self, err):\r
389 self.format_ok = False\r
390 err = 'Patch format error: ' + err\r
391 err2 = 'Line: ' + self.lines[self.line_num].rstrip()\r
392 self.error(err, err2)\r
393\r
394 def error(self, *err):\r
395 if self.ok and Verbose.level > Verbose.ONELINE:\r
396 print('Code format is not valid:')\r
397 self.ok = False\r
398 if Verbose.level < Verbose.NORMAL:\r
399 return\r
400 count = 0\r
401 for line in err:\r
402 prefix = (' *', ' ')[count > 0]\r
403 print(prefix, line)\r
404 count += 1\r
405\r
406class CheckOnePatch:\r
407 """Checks the contents of a git email formatted patch.\r
408\r
409 Various checks are performed on both the commit message and the\r
410 patch content.\r
411 """\r
412\r
413 def __init__(self, name, patch):\r
414 self.patch = patch\r
415 self.find_patch_pieces()\r
416\r
417 msg_check = CommitMessageCheck(self.commit_subject, self.commit_msg)\r
418 msg_ok = msg_check.ok\r
419\r
420 diff_ok = True\r
421 if self.diff is not None:\r
422 diff_check = GitDiffCheck(self.diff)\r
423 diff_ok = diff_check.ok\r
424\r
425 self.ok = msg_ok and diff_ok\r
426\r
427 if Verbose.level == Verbose.ONELINE:\r
428 if self.ok:\r
429 result = 'ok'\r
430 else:\r
431 result = list()\r
432 if not msg_ok:\r
433 result.append('commit message')\r
434 if not diff_ok:\r
435 result.append('diff content')\r
436 result = 'bad ' + ' and '.join(result)\r
437 print(name, result)\r
438\r
439\r
440 git_diff_re = re.compile(r'''\r
441 ^ diff \s+ --git \s+ a/.+ \s+ b/.+ $\r
442 ''',\r
443 re.IGNORECASE | re.VERBOSE | re.MULTILINE)\r
444\r
445 stat_re = \\r
446 re.compile(r'''\r
447 (?P<commit_message> [\s\S\r\n]* )\r
448 (?P<stat>\r
449 ^ --- $ [\r\n]+\r
450 (?: ^ \s+ .+ \s+ \| \s+ \d+ \s+ \+* \-*\r
451 $ [\r\n]+ )+\r
452 [\s\S\r\n]+\r
453 )\r
454 ''',\r
455 re.IGNORECASE | re.VERBOSE | re.MULTILINE)\r
456\r
e709bbb1
YZ
457 subject_prefix_re = \\r
458 re.compile(r'''^\r
459 \s* (\[\r
460 [^\[\]]* # Allow all non-brackets\r
461 \])* \s*\r
462 ''',\r
463 re.VERBOSE)\r
464\r
a7e173b0
JJ
465 def find_patch_pieces(self):\r
466 if sys.version_info < (3, 0):\r
467 patch = self.patch.encode('ascii', 'ignore')\r
468 else:\r
469 patch = self.patch\r
470\r
471 self.commit_msg = None\r
472 self.stat = None\r
473 self.commit_subject = None\r
474 self.commit_prefix = None\r
475 self.diff = None\r
476\r
477 if patch.startswith('diff --git'):\r
478 self.diff = patch\r
479 return\r
480\r
481 pmail = email.message_from_string(patch)\r
482 parts = list(pmail.walk())\r
483 assert(len(parts) == 1)\r
484 assert(parts[0].get_content_type() == 'text/plain')\r
485 content = parts[0].get_payload(decode=True).decode('utf-8', 'ignore')\r
486\r
487 mo = self.git_diff_re.search(content)\r
488 if mo is not None:\r
489 self.diff = content[mo.start():]\r
490 content = content[:mo.start()]\r
491\r
492 mo = self.stat_re.search(content)\r
493 if mo is None:\r
494 self.commit_msg = content\r
495 else:\r
496 self.stat = mo.group('stat')\r
497 self.commit_msg = mo.group('commit_message')\r
498\r
499 self.commit_subject = pmail['subject'].replace('\r\n', '')\r
500 self.commit_subject = self.commit_subject.replace('\n', '')\r
e709bbb1 501 self.commit_subject = self.subject_prefix_re.sub('', self.commit_subject, 1)\r
a7e173b0
JJ
502\r
503class CheckGitCommits:\r
504 """Reads patches from git based on the specified git revision range.\r
505\r
506 The patches are read from git, and then checked.\r
507 """\r
508\r
509 def __init__(self, rev_spec, max_count):\r
510 commits = self.read_commit_list_from_git(rev_spec, max_count)\r
511 if len(commits) == 1 and Verbose.level > Verbose.ONELINE:\r
512 commits = [ rev_spec ]\r
513 self.ok = True\r
514 blank_line = False\r
515 for commit in commits:\r
516 if Verbose.level > Verbose.ONELINE:\r
517 if blank_line:\r
518 print()\r
519 else:\r
520 blank_line = True\r
521 print('Checking git commit:', commit)\r
522 patch = self.read_patch_from_git(commit)\r
523 self.ok &= CheckOnePatch(commit, patch).ok\r
5ac4548c
JC
524 if not commits:\r
525 print("Couldn't find commit matching: '{}'".format(rev_spec))\r
a7e173b0
JJ
526\r
527 def read_commit_list_from_git(self, rev_spec, max_count):\r
528 # Run git to get the commit patch\r
529 cmd = [ 'rev-list', '--abbrev-commit', '--no-walk' ]\r
530 if max_count is not None:\r
531 cmd.append('--max-count=' + str(max_count))\r
532 cmd.append(rev_spec)\r
533 out = self.run_git(*cmd)\r
5ac4548c 534 return out.split() if out else []\r
a7e173b0
JJ
535\r
536 def read_patch_from_git(self, commit):\r
537 # Run git to get the commit patch\r
538 return self.run_git('show', '--pretty=email', commit)\r
539\r
540 def run_git(self, *args):\r
541 cmd = [ 'git' ]\r
542 cmd += args\r
543 p = subprocess.Popen(cmd,\r
544 stdout=subprocess.PIPE,\r
545 stderr=subprocess.STDOUT)\r
5ac4548c 546 Result = p.communicate()\r
6460513a 547 return Result[0].decode('utf-8', 'ignore') if Result[0] and Result[0].find(b"fatal")!=0 else None\r
a7e173b0
JJ
548\r
549class CheckOnePatchFile:\r
550 """Performs a patch check for a single file.\r
551\r
552 stdin is used when the filename is '-'.\r
553 """\r
554\r
555 def __init__(self, patch_filename):\r
556 if patch_filename == '-':\r
557 patch = sys.stdin.read()\r
558 patch_filename = 'stdin'\r
559 else:\r
560 f = open(patch_filename, 'rb')\r
561 patch = f.read().decode('utf-8', 'ignore')\r
562 f.close()\r
563 if Verbose.level > Verbose.ONELINE:\r
564 print('Checking patch file:', patch_filename)\r
565 self.ok = CheckOnePatch(patch_filename, patch).ok\r
566\r
567class CheckOneArg:\r
568 """Performs a patch check for a single command line argument.\r
569\r
570 The argument will be handed off to a file or git-commit based\r
571 checker.\r
572 """\r
573\r
574 def __init__(self, param, max_count=None):\r
575 self.ok = True\r
576 if param == '-' or os.path.exists(param):\r
577 checker = CheckOnePatchFile(param)\r
578 else:\r
579 checker = CheckGitCommits(param, max_count)\r
580 self.ok = checker.ok\r
581\r
582class PatchCheckApp:\r
583 """Checks patches based on the command line arguments."""\r
584\r
585 def __init__(self):\r
586 self.parse_options()\r
587 patches = self.args.patches\r
588\r
589 if len(patches) == 0:\r
590 patches = [ 'HEAD' ]\r
591\r
592 self.ok = True\r
593 self.count = None\r
594 for patch in patches:\r
595 self.process_one_arg(patch)\r
596\r
597 if self.count is not None:\r
598 self.process_one_arg('HEAD')\r
599\r
600 if self.ok:\r
601 self.retval = 0\r
602 else:\r
603 self.retval = -1\r
604\r
605 def process_one_arg(self, arg):\r
606 if len(arg) >= 2 and arg[0] == '-':\r
607 try:\r
608 self.count = int(arg[1:])\r
609 return\r
610 except ValueError:\r
611 pass\r
612 self.ok &= CheckOneArg(arg, self.count).ok\r
613 self.count = None\r
614\r
615 def parse_options(self):\r
616 parser = argparse.ArgumentParser(description=__copyright__)\r
617 parser.add_argument('--version', action='version',\r
618 version='%(prog)s ' + VersionNumber)\r
619 parser.add_argument('patches', nargs='*',\r
620 help='[patch file | git rev list]')\r
621 group = parser.add_mutually_exclusive_group()\r
622 group.add_argument("--oneline",\r
623 action="store_true",\r
624 help="Print one result per line")\r
625 group.add_argument("--silent",\r
626 action="store_true",\r
627 help="Print nothing")\r
628 self.args = parser.parse_args()\r
629 if self.args.oneline:\r
630 Verbose.level = Verbose.ONELINE\r
631 if self.args.silent:\r
632 Verbose.level = Verbose.SILENT\r
633\r
634if __name__ == "__main__":\r
635 sys.exit(PatchCheckApp().retval)\r