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