git_commit.py revision 1.1.1.2 1 #!/usr/bin/env python3
2 #
3 # This file is part of GCC.
4 #
5 # GCC is free software; you can redistribute it and/or modify it under
6 # the terms of the GNU General Public License as published by the Free
7 # Software Foundation; either version 3, or (at your option) any later
8 # version.
9 #
10 # GCC is distributed in the hope that it will be useful, but WITHOUT ANY
11 # WARRANTY; without even the implied warranty of MERCHANTABILITY or
12 # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
13 # for more details.
14 #
15 # You should have received a copy of the GNU General Public License
16 # along with GCC; see the file COPYING3. If not see
17 # <http://www.gnu.org/licenses/>. */
18
19 import difflib
20 import os
21 import re
22 import sys
23
24 default_changelog_locations = {
25 'c++tools',
26 'config',
27 'contrib',
28 'contrib/header-tools',
29 'contrib/reghunt',
30 'contrib/regression',
31 'fixincludes',
32 'gcc/ada',
33 'gcc/analyzer',
34 'gcc/brig',
35 'gcc/c',
36 'gcc/c-family',
37 'gcc',
38 'gcc/cp',
39 'gcc/d',
40 'gcc/fortran',
41 'gcc/go',
42 'gcc/jit',
43 'gcc/lto',
44 'gcc/objc',
45 'gcc/objcp',
46 'gcc/po',
47 'gcc/testsuite',
48 'gnattools',
49 'gotools',
50 'include',
51 'intl',
52 'libada',
53 'libatomic',
54 'libbacktrace',
55 'libcc1',
56 'libcody',
57 'libcpp',
58 'libcpp/po',
59 'libdecnumber',
60 'libffi',
61 'libgcc',
62 'libgcc/config/avr/libf7',
63 'libgcc/config/libbid',
64 'libgfortran',
65 'libgomp',
66 'libhsail-rt',
67 'libiberty',
68 'libitm',
69 'libobjc',
70 'liboffloadmic',
71 'libphobos',
72 'libquadmath',
73 'libsanitizer',
74 'libssp',
75 'libstdc++-v3',
76 'libvtv',
77 'lto-plugin',
78 'maintainer-scripts',
79 'zlib'}
80
81 bug_components = {
82 'ada',
83 'analyzer',
84 'boehm-gc',
85 'bootstrap',
86 'c',
87 'c++',
88 'd',
89 'debug',
90 'demangler',
91 'driver',
92 'fastjar',
93 'fortran',
94 'gcov-profile',
95 'go',
96 'hsa',
97 'inline-asm',
98 'ipa',
99 'java',
100 'jit',
101 'libbacktrace',
102 'libf2c',
103 'libffi',
104 'libfortran',
105 'libgcc',
106 'libgcj',
107 'libgomp',
108 'libitm',
109 'libobjc',
110 'libquadmath',
111 'libstdc++',
112 'lto',
113 'middle-end',
114 'modula2',
115 'objc',
116 'objc++',
117 'other',
118 'pch',
119 'pending',
120 'plugins',
121 'preprocessor',
122 'regression',
123 'rtl-optimization',
124 'sanitizer',
125 'spam',
126 'target',
127 'testsuite',
128 'translation',
129 'tree-optimization',
130 'web'}
131
132 ignored_prefixes = {
133 'gcc/d/dmd/',
134 'gcc/go/gofrontend/',
135 'gcc/testsuite/gdc.test/',
136 'gcc/testsuite/go.test/test/',
137 'libffi/',
138 'libgo/',
139 'libphobos/libdruntime/',
140 'libphobos/src/',
141 'libsanitizer/',
142 }
143
144 wildcard_prefixes = {
145 'gcc/testsuite/',
146 'libstdc++-v3/doc/html/',
147 'libstdc++-v3/testsuite/'
148 }
149
150 misc_files = {
151 'gcc/DATESTAMP',
152 'gcc/BASE-VER',
153 'gcc/DEV-PHASE'
154 }
155
156 author_line_regex = \
157 re.compile(r'^(?P<datetime>\d{4}-\d{2}-\d{2})\ {2}(?P<name>.* <.*>)')
158 additional_author_regex = re.compile(r'^\t(?P<spaces>\ *)?(?P<name>.* <.*>)')
159 changelog_regex = re.compile(r'^(?:[fF]or +)?([a-z0-9+-/]*)ChangeLog:?')
160 subject_pr_regex = re.compile(r'(^|\W)PR\s+(?P<component>[a-zA-Z+-]+)/(?P<pr>\d{4,7})')
161 subject_pr2_regex = re.compile(r'[(\[]PR\s*(?P<pr>\d{4,7})[)\]]')
162 pr_regex = re.compile(r'\tPR (?P<component>[a-z+-]+\/)?(?P<pr>[0-9]+)$')
163 dr_regex = re.compile(r'\tDR ([0-9]+)$')
164 star_prefix_regex = re.compile(r'\t\*(?P<spaces>\ *)(?P<content>.*)')
165 end_of_location_regex = re.compile(r'[\[<(:]')
166 item_empty_regex = re.compile(r'\t(\* \S+ )?\(\S+\):\s*$')
167 item_parenthesis_regex = re.compile(r'\t(\*|\(\S+\):)')
168 revert_regex = re.compile(r'This reverts commit (?P<hash>[0-9a-f]+)\.$')
169 cherry_pick_regex = re.compile(r'cherry picked from commit (?P<hash>\w+)')
170
171 LINE_LIMIT = 100
172 TAB_WIDTH = 8
173 CO_AUTHORED_BY_PREFIX = 'co-authored-by: '
174
175 REVIEW_PREFIXES = ('reviewed-by: ', 'reviewed-on: ', 'signed-off-by: ',
176 'acked-by: ', 'tested-by: ', 'reported-by: ',
177 'suggested-by: ')
178 DATE_FORMAT = '%Y-%m-%d'
179
180
181 def decode_path(path):
182 # When core.quotepath is true (default value), utf8 chars are encoded like:
183 # "b/ko\304\215ka.txt"
184 #
185 # The upstream bug is fixed:
186 # https://github.com/gitpython-developers/GitPython/issues/1099
187 #
188 # but we still need a workaround for older versions of the library.
189 # Please take a look at the explanation of the transformation:
190 # https://stackoverflow.com/questions/990169/how-do-convert-unicode-escape-sequences-to-unicode-characters-in-a-python-string
191
192 if path.startswith('"') and path.endswith('"'):
193 return (path.strip('"').encode('utf8').decode('unicode-escape')
194 .encode('latin-1').decode('utf8'))
195 else:
196 return path
197
198
199 class Error:
200 def __init__(self, message, line=None, details=None):
201 self.message = message
202 self.line = line
203 self.details = details
204
205 def __repr__(self):
206 s = self.message
207 if self.line:
208 s += ': "%s"' % self.line
209 return s
210
211
212 class ChangeLogEntry:
213 def __init__(self, folder, authors, prs):
214 self.folder = folder
215 # The 'list.copy()' function is not available before Python 3.3
216 self.author_lines = list(authors)
217 self.initial_prs = list(prs)
218 self.prs = list(prs)
219 self.lines = []
220 self.files = []
221 self.file_patterns = []
222 self.parentheses_stack = []
223
224 def parse_file_names(self):
225 # Whether the content currently processed is between a star prefix the
226 # end of the file list: a colon or an open paren.
227 in_location = False
228
229 for line in self.lines:
230 # If this line matches the star prefix, start the location
231 # processing on the information that follows the star.
232 # Note that we need to skip macro names that can be in form of:
233 #
234 # * config/i386/i386.md (*fix_trunc<mode>_i387_1,
235 # *add<mode>3_ne, *add<mode>3_eq_0, *add<mode>3_ne_0,
236 # *fist<mode>2_<rounding>_1, *<code><mode>3_1):
237 #
238 m = star_prefix_regex.match(line)
239 if m and len(m.group('spaces')) == 1:
240 in_location = True
241 line = m.group('content')
242
243 if in_location:
244 # Strip everything that is not a filename in "line":
245 # entities "(NAME)", cases "<PATTERN>", conditions
246 # "[COND]", entry text (the colon, if present, and
247 # anything that follows it).
248 m = end_of_location_regex.search(line)
249 if m:
250 line = line[:m.start()]
251 in_location = False
252
253 # At this point, all that's left is a list of filenames
254 # separated by commas and whitespaces.
255 for file in line.split(','):
256 file = file.strip()
257 if file:
258 if file.endswith('*'):
259 self.file_patterns.append(file[:-1])
260 else:
261 self.files.append(file)
262
263 @property
264 def datetime(self):
265 for author in self.author_lines:
266 if author[1]:
267 return author[1]
268 return None
269
270 @property
271 def authors(self):
272 return [author_line[0] for author_line in self.author_lines]
273
274 @property
275 def is_empty(self):
276 return not self.lines and self.prs == self.initial_prs
277
278 def contains_author(self, author):
279 for author_lines in self.author_lines:
280 if author_lines[0] == author:
281 return True
282 return False
283
284
285 class GitInfo:
286 def __init__(self, hexsha, date, author, lines, modified_files):
287 self.hexsha = hexsha
288 self.date = date
289 self.author = author
290 self.lines = lines
291 self.modified_files = modified_files
292
293
294 class GitCommit:
295 def __init__(self, info, commit_to_info_hook=None, ref_name=None):
296 self.original_info = info
297 self.info = info
298 self.message = None
299 self.changes = None
300 self.changelog_entries = []
301 self.errors = []
302 self.top_level_authors = []
303 self.co_authors = []
304 self.top_level_prs = []
305 self.subject_prs = set()
306 self.cherry_pick_commit = None
307 self.revert_commit = None
308 self.commit_to_info_hook = commit_to_info_hook
309 self.init_changelog_locations(ref_name)
310
311 # Skip Update copyright years commits
312 if self.info.lines and self.info.lines[0] == 'Update copyright years.':
313 return
314
315 if self.info.lines and len(self.info.lines) > 1 and self.info.lines[1]:
316 self.errors.append(Error('Expected empty second line in commit message', info.lines[0]))
317
318 # Identify first if the commit is a Revert commit
319 for line in self.info.lines:
320 m = revert_regex.fullmatch(line)
321 if m:
322 self.revert_commit = m.group('hash')
323 break
324 if self.revert_commit:
325 self.info = self.commit_to_info_hook(self.revert_commit)
326
327 # The following happens for get_email.py:
328 if not self.info:
329 return
330
331 self.check_commit_email()
332
333 # Extract PR numbers form the subject line
334 # Match either [PRnnnn] / (PRnnnn) or PR component/nnnn
335 if self.info.lines and not self.revert_commit:
336 self.subject_prs = {m.group('pr') for m in subject_pr2_regex.finditer(info.lines[0])}
337 for m in subject_pr_regex.finditer(info.lines[0]):
338 if not m.group('component') in bug_components:
339 self.errors.append(Error('invalid PR component in subject', info.lines[0]))
340 self.subject_prs.add(m.group('pr'))
341
342 # Allow complete deletion of ChangeLog files in a commit
343 project_files = [f for f in self.info.modified_files
344 if (self.is_changelog_filename(f[0], allow_suffix=True) and f[1] != 'D')
345 or f[0] in misc_files]
346 ignored_files = [f for f in self.info.modified_files
347 if self.in_ignored_location(f[0])]
348 if len(project_files) == len(self.info.modified_files):
349 # All modified files are only MISC files
350 return
351 elif project_files:
352 err = 'ChangeLog, DATESTAMP, BASE-VER and DEV-PHASE updates ' \
353 'should be done separately from normal commits\n' \
354 '(note: ChangeLog entries will be automatically ' \
355 'added by a cron job)'
356 self.errors.append(Error(err))
357 return
358
359 all_are_ignored = (len(project_files) + len(ignored_files)
360 == len(self.info.modified_files))
361 self.parse_lines(all_are_ignored)
362 if self.changes:
363 self.parse_changelog()
364 self.parse_file_names()
365 self.check_for_empty_description()
366 self.check_for_broken_parentheses()
367 self.deduce_changelog_locations()
368 self.check_file_patterns()
369 if not self.errors:
370 self.check_mentioned_files()
371 self.check_for_correct_changelog()
372 if self.subject_prs:
373 self.errors.append(Error('PR %s in subject but not in changelog' %
374 ', '.join(self.subject_prs), self.info.lines[0]))
375
376 @property
377 def success(self):
378 return not self.errors
379
380 @property
381 def new_files(self):
382 return [x[0] for x in self.info.modified_files if x[1] == 'A']
383
384 @classmethod
385 def is_changelog_filename(cls, path, allow_suffix=False):
386 basename = os.path.basename(path)
387 if basename == 'ChangeLog':
388 return True
389 elif allow_suffix and basename.startswith('ChangeLog'):
390 return True
391 else:
392 return False
393
394 def find_changelog_location(self, name):
395 if name.startswith('\t'):
396 name = name[1:]
397 if name.endswith(':'):
398 name = name[:-1]
399 if name.endswith('/'):
400 name = name[:-1]
401 return name if name in self.changelog_locations else None
402
403 @classmethod
404 def format_git_author(cls, author):
405 assert '<' in author
406 return author.replace('<', ' <')
407
408 @classmethod
409 def parse_git_name_status(cls, string):
410 modified_files = []
411 for entry in string.split('\n'):
412 parts = entry.split('\t')
413 t = parts[0]
414 if t == 'A' or t == 'D' or t == 'M':
415 modified_files.append((parts[1], t))
416 elif t.startswith('R'):
417 modified_files.append((parts[1], 'D'))
418 modified_files.append((parts[2], 'A'))
419 return modified_files
420
421 def init_changelog_locations(self, ref_name):
422 self.changelog_locations = list(default_changelog_locations)
423 if ref_name:
424 version = sys.maxsize
425 if 'releases/gcc-' in ref_name:
426 version = int(ref_name.split('-')[-1])
427 if version >= 12:
428 # HSA and BRIG were removed in GCC 12
429 self.changelog_locations.remove('gcc/brig')
430 self.changelog_locations.remove('libhsail-rt')
431
432 def parse_lines(self, all_are_ignored):
433 body = self.info.lines
434
435 for i, b in enumerate(body):
436 if not b:
437 continue
438 if (changelog_regex.match(b) or self.find_changelog_location(b)
439 or star_prefix_regex.match(b) or pr_regex.match(b)
440 or dr_regex.match(b) or author_line_regex.match(b)
441 or b.lower().startswith(CO_AUTHORED_BY_PREFIX)):
442 self.changes = body[i:]
443 return
444 if not all_are_ignored:
445 self.errors.append(Error('cannot find a ChangeLog location in '
446 'message'))
447
448 def parse_changelog(self):
449 last_entry = None
450 will_deduce = False
451 for line in self.changes:
452 if not line:
453 if last_entry and will_deduce:
454 last_entry = None
455 continue
456 if line != line.rstrip():
457 self.errors.append(Error('trailing whitespace', line))
458 if len(line.replace('\t', ' ' * TAB_WIDTH)) > LINE_LIMIT:
459 # support long filenames
460 if not line.startswith('\t* ') or not line.endswith(':') or ' ' in line[3:-1]:
461 self.errors.append(Error('line exceeds %d character limit'
462 % LINE_LIMIT, line))
463 m = changelog_regex.match(line)
464 if m:
465 last_entry = ChangeLogEntry(m.group(1).rstrip('/'),
466 self.top_level_authors,
467 self.top_level_prs)
468 self.changelog_entries.append(last_entry)
469 elif self.find_changelog_location(line):
470 last_entry = ChangeLogEntry(self.find_changelog_location(line),
471 self.top_level_authors,
472 self.top_level_prs)
473 self.changelog_entries.append(last_entry)
474 else:
475 author_tuple = None
476 pr_line = None
477 if author_line_regex.match(line):
478 m = author_line_regex.match(line)
479 author_tuple = (m.group('name'), m.group('datetime'))
480 elif additional_author_regex.match(line):
481 m = additional_author_regex.match(line)
482 if len(m.group('spaces')) != 4:
483 msg = 'additional author must be indented with '\
484 'one tab and four spaces'
485 self.errors.append(Error(msg, line))
486 else:
487 author_tuple = (m.group('name'), None)
488 elif pr_regex.match(line):
489 m = pr_regex.match(line)
490 component = m.group('component')
491 pr = m.group('pr')
492 if not component:
493 self.errors.append(Error('missing PR component', line))
494 continue
495 elif not component[:-1] in bug_components:
496 self.errors.append(Error('invalid PR component', line))
497 continue
498 else:
499 pr_line = line.lstrip()
500 if pr in self.subject_prs:
501 self.subject_prs.remove(pr)
502 elif dr_regex.match(line):
503 pr_line = line.lstrip()
504
505 lowered_line = line.lower()
506 if lowered_line.startswith(CO_AUTHORED_BY_PREFIX):
507 name = line[len(CO_AUTHORED_BY_PREFIX):]
508 author = self.format_git_author(name)
509 self.co_authors.append(author)
510 continue
511 elif lowered_line.startswith(REVIEW_PREFIXES):
512 continue
513 else:
514 m = cherry_pick_regex.search(line)
515 if m:
516 commit = m.group('hash')
517 if self.cherry_pick_commit:
518 msg = 'multiple cherry pick lines'
519 self.errors.append(Error(msg, line))
520 else:
521 self.cherry_pick_commit = commit
522 continue
523
524 # ChangeLog name will be deduced later
525 if not last_entry:
526 if author_tuple:
527 self.top_level_authors.append(author_tuple)
528 continue
529 elif pr_line:
530 # append to top_level_prs only when we haven't met
531 # a ChangeLog entry
532 if (pr_line not in self.top_level_prs
533 and not self.changelog_entries):
534 self.top_level_prs.append(pr_line)
535 continue
536 else:
537 last_entry = ChangeLogEntry(None,
538 self.top_level_authors,
539 self.top_level_prs)
540 self.changelog_entries.append(last_entry)
541 will_deduce = True
542 elif author_tuple:
543 if not last_entry.contains_author(author_tuple[0]):
544 last_entry.author_lines.append(author_tuple)
545 continue
546
547 if not line.startswith('\t'):
548 err = Error('line should start with a tab', line)
549 self.errors.append(err)
550 elif pr_line:
551 last_entry.prs.append(pr_line)
552 else:
553 m = star_prefix_regex.match(line)
554 if m:
555 if (len(m.group('spaces')) != 1 and
556 not last_entry.parentheses_stack):
557 msg = 'one space should follow asterisk'
558 self.errors.append(Error(msg, line))
559 else:
560 content = m.group('content')
561 parts = content.split(':')
562 if len(parts) > 1:
563 for needle in ('()', '[]', '<>'):
564 if ' ' + needle in parts[0]:
565 msg = f'empty group "{needle}" found'
566 self.errors.append(Error(msg, line))
567 last_entry.lines.append(line)
568 self.process_parentheses(last_entry, line)
569 else:
570 if last_entry.is_empty:
571 msg = 'first line should start with a tab, ' \
572 'an asterisk and a space'
573 self.errors.append(Error(msg, line))
574 else:
575 last_entry.lines.append(line)
576 self.process_parentheses(last_entry, line)
577
578 def process_parentheses(self, last_entry, line):
579 for c in line:
580 if c == '(':
581 last_entry.parentheses_stack.append(line)
582 elif c == ')':
583 if not last_entry.parentheses_stack:
584 msg = 'bad wrapping of parenthesis'
585 self.errors.append(Error(msg, line))
586 else:
587 del last_entry.parentheses_stack[-1]
588
589 def parse_file_names(self):
590 for entry in self.changelog_entries:
591 entry.parse_file_names()
592
593 def check_file_patterns(self):
594 for entry in self.changelog_entries:
595 for pattern in entry.file_patterns:
596 name = os.path.join(entry.folder, pattern)
597 if not [name.startswith(pr) for pr in wildcard_prefixes]:
598 msg = 'unsupported wildcard prefix'
599 self.errors.append(Error(msg, name))
600
601 def check_for_empty_description(self):
602 for entry in self.changelog_entries:
603 for i, line in enumerate(entry.lines):
604 if (item_empty_regex.match(line) and
605 (i == len(entry.lines) - 1
606 or not entry.lines[i+1].strip()
607 or item_parenthesis_regex.match(entry.lines[i+1]))):
608 msg = 'missing description of a change'
609 self.errors.append(Error(msg, line))
610
611 def check_for_broken_parentheses(self):
612 for entry in self.changelog_entries:
613 if entry.parentheses_stack:
614 msg = 'bad parentheses wrapping'
615 self.errors.append(Error(msg, entry.parentheses_stack[-1]))
616
617 def get_file_changelog_location(self, changelog_file):
618 for file in self.info.modified_files:
619 if file[0] == changelog_file:
620 # root ChangeLog file
621 return ''
622 index = file[0].find('/' + changelog_file)
623 if index != -1:
624 return file[0][:index]
625 return None
626
627 def deduce_changelog_locations(self):
628 for entry in self.changelog_entries:
629 if not entry.folder:
630 changelog = None
631 for file in entry.files:
632 location = self.get_file_changelog_location(file)
633 if (location == ''
634 or (location and location in self.changelog_locations)):
635 if changelog and changelog != location:
636 msg = 'could not deduce ChangeLog file, ' \
637 'not unique location'
638 self.errors.append(Error(msg))
639 return
640 changelog = location
641 if changelog is not None:
642 entry.folder = changelog
643 else:
644 msg = 'could not deduce ChangeLog file'
645 self.errors.append(Error(msg))
646
647 @classmethod
648 def in_ignored_location(cls, path):
649 for ignored in ignored_prefixes:
650 if path.startswith(ignored):
651 return True
652 return False
653
654 def get_changelog_by_path(self, path):
655 components = path.split('/')
656 while components:
657 if '/'.join(components) in self.changelog_locations:
658 break
659 components = components[:-1]
660 return '/'.join(components)
661
662 def check_mentioned_files(self):
663 folder_count = len([x.folder for x in self.changelog_entries])
664 assert folder_count == len(self.changelog_entries)
665
666 mentioned_files = set()
667 mentioned_patterns = []
668 used_patterns = set()
669 for entry in self.changelog_entries:
670 if not entry.files and not entry.file_patterns:
671 msg = 'no files mentioned for ChangeLog in directory'
672 self.errors.append(Error(msg, entry.folder))
673 assert not entry.folder.endswith('/')
674 for file in entry.files:
675 if not self.is_changelog_filename(file):
676 item = os.path.join(entry.folder, file)
677 if item in mentioned_files:
678 msg = 'same file specified multiple times'
679 self.errors.append(Error(msg, file))
680 else:
681 mentioned_files.add(item)
682 for pattern in entry.file_patterns:
683 mentioned_patterns.append(os.path.join(entry.folder, pattern))
684
685 cand = [x[0] for x in self.info.modified_files
686 if not self.is_changelog_filename(x[0])]
687 changed_files = set(cand)
688 for file in sorted(mentioned_files - changed_files):
689 msg = 'unchanged file mentioned in a ChangeLog'
690 candidates = difflib.get_close_matches(file, changed_files, 1)
691 details = None
692 if candidates:
693 msg += f' (did you mean "{candidates[0]}"?)'
694 details = '\n'.join(difflib.Differ().compare([file], [candidates[0]])).rstrip()
695 self.errors.append(Error(msg, file, details))
696 for file in sorted(changed_files - mentioned_files):
697 if not self.in_ignored_location(file):
698 if file in self.new_files:
699 changelog_location = self.get_changelog_by_path(file)
700 # Python2: we cannot use next(filter(...))
701 entries = filter(lambda x: x.folder == changelog_location,
702 self.changelog_entries)
703 entries = list(entries)
704 entry = entries[0] if entries else None
705 if not entry:
706 prs = self.top_level_prs
707 if not prs:
708 # if all ChangeLog entries have identical PRs
709 # then use them
710 prs = self.changelog_entries[0].prs
711 for entry in self.changelog_entries:
712 if entry.prs != prs:
713 prs = []
714 break
715 entry = ChangeLogEntry(changelog_location,
716 self.top_level_authors,
717 prs)
718 self.changelog_entries.append(entry)
719 # strip prefix of the file
720 assert file.startswith(entry.folder)
721 # do not allow auto-addition of New files
722 # for the top-level folder
723 if entry.folder:
724 file = file[len(entry.folder):].lstrip('/')
725 entry.lines.append('\t* %s: New file.' % file)
726 entry.files.append(file)
727 else:
728 msg = 'new file in the top-level folder not mentioned in a ChangeLog'
729 self.errors.append(Error(msg, file))
730 else:
731 used_pattern = [p for p in mentioned_patterns
732 if file.startswith(p)]
733 used_pattern = used_pattern[0] if used_pattern else None
734 if used_pattern:
735 used_patterns.add(used_pattern)
736 else:
737 msg = 'changed file not mentioned in a ChangeLog'
738 self.errors.append(Error(msg, file))
739
740 for pattern in mentioned_patterns:
741 if pattern not in used_patterns:
742 error = "pattern doesn't match any changed files"
743 self.errors.append(Error(error, pattern))
744
745 def check_for_correct_changelog(self):
746 for entry in self.changelog_entries:
747 for file in entry.files:
748 full_path = os.path.join(entry.folder, file)
749 changelog_location = self.get_changelog_by_path(full_path)
750 if changelog_location != entry.folder:
751 msg = 'wrong ChangeLog location "%s", should be "%s"'
752 err = Error(msg % (entry.folder, changelog_location), file)
753 self.errors.append(err)
754
755 @classmethod
756 def format_authors_in_changelog(cls, authors, timestamp, prefix=''):
757 output = ''
758 for i, author in enumerate(authors):
759 if i == 0:
760 output += '%s%s %s\n' % (prefix, timestamp, author)
761 else:
762 output += '%s\t %s\n' % (prefix, author)
763 output += '\n'
764 return output
765
766 def to_changelog_entries(self, use_commit_ts=False):
767 current_timestamp = self.info.date.strftime(DATE_FORMAT)
768 for entry in self.changelog_entries:
769 output = ''
770 timestamp = entry.datetime
771 if self.revert_commit:
772 timestamp = current_timestamp
773 orig_date = self.original_info.date
774 current_timestamp = orig_date.strftime(DATE_FORMAT)
775 elif self.cherry_pick_commit:
776 info = self.commit_to_info_hook(self.cherry_pick_commit)
777 # it can happen that it is a cherry-pick for a different
778 # repository
779 if info:
780 timestamp = info.date.strftime(DATE_FORMAT)
781 else:
782 timestamp = current_timestamp
783 elif not timestamp or use_commit_ts:
784 timestamp = current_timestamp
785 authors = entry.authors if entry.authors else [self.info.author]
786 # add Co-Authored-By authors to all ChangeLog entries
787 for author in self.co_authors:
788 if author not in authors:
789 authors.append(author)
790
791 if self.cherry_pick_commit or self.revert_commit:
792 original_author = self.original_info.author
793 output += self.format_authors_in_changelog([original_author],
794 current_timestamp)
795 if self.revert_commit:
796 output += '\tRevert:\n'
797 else:
798 output += '\tBackported from master:\n'
799 output += self.format_authors_in_changelog(authors,
800 timestamp, '\t')
801 else:
802 output += self.format_authors_in_changelog(authors, timestamp)
803 for pr in entry.prs:
804 output += '\t%s\n' % pr
805 for line in entry.lines:
806 output += line + '\n'
807 yield (entry.folder, output.rstrip())
808
809 def print_output(self):
810 for entry, output in self.to_changelog_entries():
811 print('------ %s/ChangeLog ------ ' % entry)
812 print(output)
813
814 def print_errors(self):
815 print('Errors:')
816 for error in self.errors:
817 print(error)
818
819 def check_commit_email(self):
820 # Parse 'Martin Liska <mliska (at] suse.cz>'
821 email = self.info.author.split(' ')[-1].strip('<>')
822
823 # Verify that all characters are ASCII
824 # TODO: Python 3.7 provides a nicer function: isascii
825 if len(email) != len(email.encode()):
826 self.errors.append(Error(f'non-ASCII characters in git commit email address ({email})'))
827