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