3 # Copyright (C) 2020-2023 Free Software Foundation, Inc.
5 # This file is part of GCC.
7 # GCC is free software; you can redistribute it and/or modify it under
8 # the terms of the GNU General Public License as published by the Free
9 # Software Foundation; either version 3, or (at your option) any later
12 # GCC is distributed in the hope that it will be useful, but WITHOUT ANY
13 # WARRANTY; without even the implied warranty of MERCHANTABILITY or
14 # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
17 # You should have received a copy of the GNU General Public License
18 # along with GCC; see the file COPYING3. If not see
19 # <http://www.gnu.org/licenses/>. */
25 from collections
import defaultdict
27 default_changelog_locations
= {
31 'contrib/header-tools',
67 'libgcc/config/avr/libf7',
68 'libgcc/config/libbid',
140 'gcc/go/gofrontend/',
141 'gcc/testsuite/gdc.test/',
142 'gcc/testsuite/go.test/test/',
145 'libphobos/libdruntime/',
150 wildcard_prefixes
= {
152 'libstdc++-v3/doc/html/',
153 'libstdc++-v3/testsuite/'
162 author_line_regex
= \
163 re
.compile(r
'^(?P<datetime>\d{4}-\d{2}-\d{2})\ {2}(?P<name>.* <.*>)')
164 additional_author_regex
= re
.compile(r
'^\t(?P<spaces>\ *)?(?P<name>.* <.*>)')
165 changelog_regex
= re
.compile(r
'^(?:[fF]or +)?([a-z0-9+-/]*)ChangeLog:?')
166 subject_pr_regex
= re
.compile(r
'(^|\W)PR\s+(?P<component>[a-zA-Z0-9+-]+)/(?P<pr>\d{4,7})')
167 subject_pr2_regex
= re
.compile(r
'[(\[]PR\s*(?P<pr>\d{4,7})[)\]]')
168 pr_regex
= re
.compile(r
'\tPR (?P<component>[a-z0-9+-]+\/)?(?P<pr>[0-9]+)$')
169 dr_regex
= re
.compile(r
'\tDR ([0-9]+)$')
170 star_prefix_regex
= re
.compile(r
'\t\*(?P<spaces>\ *)(?P<content>.*)')
171 end_of_location_regex
= re
.compile(r
'[\[<(:]')
172 item_empty_regex
= re
.compile(r
'\t(\* \S+ )?\(\S+\):\s*$')
173 item_parenthesis_regex
= re
.compile(r
'\t(\*|\(\S+\):)')
174 revert_regex
= re
.compile(r
'This reverts commit (?P<hash>[0-9a-f]+)\.$')
175 cherry_pick_regex
= re
.compile(r
'cherry picked from commit (?P<hash>\w+)')
179 CO_AUTHORED_BY_PREFIX
= 'co-authored-by: '
181 REVIEW_PREFIXES
= ('reviewed-by: ', 'reviewed-on: ', 'signed-off-by: ',
182 'acked-by: ', 'tested-by: ', 'reported-by: ',
184 DATE_FORMAT
= '%Y-%m-%d'
187 def decode_path(path
):
188 # When core.quotepath is true (default value), utf8 chars are encoded like:
189 # "b/ko\304\215ka.txt"
191 # The upstream bug is fixed:
192 # https://github.com/gitpython-developers/GitPython/issues/1099
194 # but we still need a workaround for older versions of the library.
195 # Please take a look at the explanation of the transformation:
196 # https://stackoverflow.com/questions/990169/how-do-convert-unicode-escape-sequences-to-unicode-characters-in-a-python-string
198 if path
.startswith('"') and path
.endswith('"'):
199 return (path
.strip('"').encode('utf8').decode('unicode-escape')
200 .encode('latin-1').decode('utf8'))
206 def __init__(self
, message
, line
=None, details
=None):
207 self
.message
= message
209 self
.details
= details
214 s
+= ': "%s"' % self
.line
218 class ChangeLogEntry
:
219 def __init__(self
, folder
, authors
, prs
):
221 # The 'list.copy()' function is not available before Python 3.3
222 self
.author_lines
= list(authors
)
223 self
.initial_prs
= list(prs
)
227 self
.file_patterns
= []
228 self
.parentheses_stack
= []
230 def parse_file_names(self
):
231 # Whether the content currently processed is between a star prefix the
232 # end of the file list: a colon or an open paren.
235 for line
in self
.lines
:
236 # If this line matches the star prefix, start the location
237 # processing on the information that follows the star.
238 # Note that we need to skip macro names that can be in form of:
240 # * config/i386/i386.md (*fix_trunc<mode>_i387_1,
241 # *add<mode>3_ne, *add<mode>3_eq_0, *add<mode>3_ne_0,
242 # *fist<mode>2_<rounding>_1, *<code><mode>3_1):
244 m
= star_prefix_regex
.match(line
)
245 if m
and len(m
.group('spaces')) == 1:
247 line
= m
.group('content')
250 # Strip everything that is not a filename in "line":
251 # entities "(NAME)", cases "<PATTERN>", conditions
252 # "[COND]", entry text (the colon, if present, and
253 # anything that follows it).
254 m
= end_of_location_regex
.search(line
)
256 line
= line
[:m
.start()]
259 # At this point, all that's left is a list of filenames
260 # separated by commas and whitespaces.
261 for file in line
.split(','):
264 if file.endswith('*'):
265 self
.file_patterns
.append(file[:-1])
267 self
.files
.append(file)
271 for author
in self
.author_lines
:
278 return [author_line
[0] for author_line
in self
.author_lines
]
282 return not self
.lines
and self
.prs
== self
.initial_prs
284 def contains_author(self
, author
):
285 for author_lines
in self
.author_lines
:
286 if author_lines
[0] == author
:
292 def __init__(self
, hexsha
, date
, author
, lines
, modified_files
):
297 self
.modified_files
= modified_files
301 def __init__(self
, info
, commit_to_info_hook
=None, ref_name
=None):
302 self
.original_info
= info
306 self
.changelog_entries
= []
309 self
.top_level_authors
= []
311 self
.top_level_prs
= []
312 self
.subject_prs
= set()
313 self
.cherry_pick_commit
= None
314 self
.revert_commit
= None
315 self
.commit_to_info_hook
= commit_to_info_hook
316 self
.init_changelog_locations(ref_name
)
318 # Skip Update copyright years commits
319 if self
.info
.lines
and self
.info
.lines
[0] == 'Update copyright years.':
322 if self
.info
.lines
and len(self
.info
.lines
) > 1 and self
.info
.lines
[1]:
323 self
.errors
.append(Error('Expected empty second line in commit message', info
.lines
[0]))
325 # Identify first if the commit is a Revert commit
326 for line
in self
.info
.lines
:
327 m
= revert_regex
.fullmatch(line
)
329 self
.revert_commit
= m
.group('hash')
331 if self
.revert_commit
:
332 # The following happens for get_email.py:
333 if not self
.commit_to_info_hook
:
334 self
.warnings
.append(f
"Invoked script can not obtain info about "
335 f
"reverted commits such as '{self.revert_commit}'")
337 self
.info
= self
.commit_to_info_hook(self
.revert_commit
)
339 self
.errors
.append(Error('Cannot find to-be-reverted commit', self
.revert_commit
))
342 self
.check_commit_email()
344 # Extract PR numbers form the subject line
345 # Match either [PRnnnn] / (PRnnnn) or PR component/nnnn
346 if self
.info
.lines
and not self
.revert_commit
:
347 self
.subject_prs
= {m
.group('pr') for m
in subject_pr2_regex
.finditer(info
.lines
[0])}
348 for m
in subject_pr_regex
.finditer(info
.lines
[0]):
349 if not m
.group('component') in bug_components
:
350 self
.errors
.append(Error('invalid PR component in subject', info
.lines
[0]))
351 self
.subject_prs
.add(m
.group('pr'))
353 # Allow complete deletion of ChangeLog files in a commit
354 project_files
= [f
for f
in self
.info
.modified_files
355 if (self
.is_changelog_filename(f
[0], allow_suffix
=True) and f
[1] != 'D')
356 or f
[0] in misc_files
]
357 ignored_files
= [f
for f
in self
.info
.modified_files
358 if self
.in_ignored_location(f
[0])]
359 if len(project_files
) == len(self
.info
.modified_files
):
360 # All modified files are only MISC files
363 err
= 'ChangeLog, DATESTAMP, BASE-VER and DEV-PHASE updates ' \
364 'should be done separately from normal commits\n' \
365 '(note: ChangeLog entries will be automatically ' \
366 'added by a cron job)'
367 self
.errors
.append(Error(err
))
370 all_are_ignored
= (len(project_files
) + len(ignored_files
)
371 == len(self
.info
.modified_files
))
372 self
.parse_lines(all_are_ignored
)
374 self
.parse_changelog()
375 self
.parse_file_names()
376 self
.check_for_empty_description()
377 self
.check_for_broken_parentheses()
378 self
.deduce_changelog_locations()
379 self
.check_file_patterns()
380 self
.check_line_start()
382 self
.check_mentioned_files()
383 self
.check_for_correct_changelog()
385 self
.errors
.append(Error('PR %s in subject but not in changelog' %
386 ', '.join(self
.subject_prs
), self
.info
.lines
[0]))
390 return not self
.errors
394 return [x
[0] for x
in self
.info
.modified_files
if x
[1] == 'A']
397 def is_changelog_filename(cls
, path
, allow_suffix
=False):
398 basename
= os
.path
.basename(path
)
399 if basename
== 'ChangeLog':
401 elif allow_suffix
and basename
.startswith('ChangeLog'):
406 def find_changelog_location(self
, name
):
407 if name
.startswith('\t'):
409 if name
.endswith(':'):
411 if name
.endswith('/'):
413 return name
if name
in self
.changelog_locations
else None
416 def format_git_author(cls
, author
):
418 return author
.replace('<', ' <')
421 def parse_git_name_status(cls
, string
):
423 for entry
in string
.split('\n'):
424 parts
= entry
.split('\t')
426 if t
== 'A' or t
== 'D' or t
== 'M':
427 modified_files
.append((parts
[1], t
))
428 elif t
.startswith('R'):
429 modified_files
.append((parts
[1], 'D'))
430 modified_files
.append((parts
[2], 'A'))
431 return modified_files
433 def init_changelog_locations(self
, ref_name
):
434 self
.changelog_locations
= list(default_changelog_locations
)
436 version
= sys
.maxsize
437 if 'releases/gcc-' in ref_name
:
438 version
= int(ref_name
.split('-')[-1])
440 # HSA and BRIG were removed in GCC 12
441 self
.changelog_locations
.remove('gcc/brig')
442 self
.changelog_locations
.remove('libhsail-rt')
444 def parse_lines(self
, all_are_ignored
):
445 body
= self
.info
.lines
447 for i
, b
in enumerate(body
):
450 if (changelog_regex
.match(b
) or self
.find_changelog_location(b
)
451 or star_prefix_regex
.match(b
) or pr_regex
.match(b
)
452 or dr_regex
.match(b
) or author_line_regex
.match(b
)
453 or b
.lower().startswith(CO_AUTHORED_BY_PREFIX
)):
454 self
.changes
= body
[i
:]
456 if not all_are_ignored
:
457 self
.errors
.append(Error('cannot find a ChangeLog location in '
460 def parse_changelog(self
):
463 for line
in self
.changes
:
465 if last_entry
and will_deduce
:
468 if line
!= line
.rstrip():
469 self
.errors
.append(Error('trailing whitespace', line
))
470 if len(line
.replace('\t', ' ' * TAB_WIDTH
)) > LINE_LIMIT
:
471 # support long filenames
472 if not line
.startswith('\t* ') or not line
.endswith(':') or ' ' in line
[3:-1]:
473 self
.errors
.append(Error('line exceeds %d character limit'
475 m
= changelog_regex
.match(line
)
477 last_entry
= ChangeLogEntry(m
.group(1).rstrip('/'),
478 self
.top_level_authors
,
480 self
.changelog_entries
.append(last_entry
)
481 elif self
.find_changelog_location(line
):
482 last_entry
= ChangeLogEntry(self
.find_changelog_location(line
),
483 self
.top_level_authors
,
485 self
.changelog_entries
.append(last_entry
)
489 if author_line_regex
.match(line
):
490 m
= author_line_regex
.match(line
)
491 author_tuple
= (m
.group('name'), m
.group('datetime'))
492 elif additional_author_regex
.match(line
):
493 m
= additional_author_regex
.match(line
)
494 if len(m
.group('spaces')) != 4:
495 msg
= 'additional author must be indented with '\
496 'one tab and four spaces'
497 self
.errors
.append(Error(msg
, line
))
499 author_tuple
= (m
.group('name'), None)
500 elif pr_regex
.match(line
):
501 m
= pr_regex
.match(line
)
502 component
= m
.group('component')
505 self
.errors
.append(Error('missing PR component', line
))
507 elif not component
[:-1] in bug_components
:
508 self
.errors
.append(Error('invalid PR component', line
))
511 pr_line
= line
.lstrip()
512 if pr
in self
.subject_prs
:
513 self
.subject_prs
.remove(pr
)
514 elif dr_regex
.match(line
):
515 pr_line
= line
.lstrip()
517 lowered_line
= line
.lower()
518 if lowered_line
.startswith(CO_AUTHORED_BY_PREFIX
):
519 name
= line
[len(CO_AUTHORED_BY_PREFIX
):]
520 author
= self
.format_git_author(name
)
521 self
.co_authors
.append(author
)
523 elif lowered_line
.startswith(REVIEW_PREFIXES
):
526 m
= cherry_pick_regex
.search(line
)
528 commit
= m
.group('hash')
529 if self
.cherry_pick_commit
:
530 msg
= 'multiple cherry pick lines'
531 self
.errors
.append(Error(msg
, line
))
533 self
.cherry_pick_commit
= commit
536 # ChangeLog name will be deduced later
539 self
.top_level_authors
.append(author_tuple
)
542 # append to top_level_prs only when we haven't met
544 if (pr_line
not in self
.top_level_prs
545 and not self
.changelog_entries
):
546 self
.top_level_prs
.append(pr_line
)
549 last_entry
= ChangeLogEntry(None,
550 self
.top_level_authors
,
552 self
.changelog_entries
.append(last_entry
)
555 if not last_entry
.contains_author(author_tuple
[0]):
556 last_entry
.author_lines
.append(author_tuple
)
559 if not line
.startswith('\t'):
560 err
= Error('line should start with a tab', line
)
561 self
.errors
.append(err
)
563 last_entry
.prs
.append(pr_line
)
565 m
= star_prefix_regex
.match(line
)
567 if (len(m
.group('spaces')) != 1 and
568 not last_entry
.parentheses_stack
):
569 msg
= 'one space should follow asterisk'
570 self
.errors
.append(Error(msg
, line
))
572 content
= m
.group('content')
573 parts
= content
.split(':')
575 for needle
in ('()', '[]', '<>'):
576 if ' ' + needle
in parts
[0]:
577 msg
= f
'empty group "{needle}" found'
578 self
.errors
.append(Error(msg
, line
))
579 last_entry
.lines
.append(line
)
580 self
.process_parentheses(last_entry
, line
)
582 if last_entry
.is_empty
:
583 msg
= 'first line should start with a tab, ' \
584 'an asterisk and a space'
585 self
.errors
.append(Error(msg
, line
))
587 last_entry
.lines
.append(line
)
588 self
.process_parentheses(last_entry
, line
)
590 def process_parentheses(self
, last_entry
, line
):
593 last_entry
.parentheses_stack
.append(line
)
595 if not last_entry
.parentheses_stack
:
596 msg
= 'bad wrapping of parenthesis'
597 self
.errors
.append(Error(msg
, line
))
599 del last_entry
.parentheses_stack
[-1]
601 def parse_file_names(self
):
602 for entry
in self
.changelog_entries
:
603 entry
.parse_file_names()
605 def check_file_patterns(self
):
606 for entry
in self
.changelog_entries
:
607 for pattern
in entry
.file_patterns
:
608 name
= os
.path
.join(entry
.folder
, pattern
)
609 if not [name
.startswith(pr
) for pr
in wildcard_prefixes
]:
610 msg
= 'unsupported wildcard prefix'
611 self
.errors
.append(Error(msg
, name
))
613 def check_for_empty_description(self
):
614 for entry
in self
.changelog_entries
:
615 for i
, line
in enumerate(entry
.lines
):
616 if (item_empty_regex
.match(line
) and
617 (i
== len(entry
.lines
) - 1
618 or not entry
.lines
[i
+1].strip()
619 or item_parenthesis_regex
.match(entry
.lines
[i
+1]))):
620 msg
= 'missing description of a change'
621 self
.errors
.append(Error(msg
, line
))
623 def check_for_broken_parentheses(self
):
624 for entry
in self
.changelog_entries
:
625 if entry
.parentheses_stack
:
626 msg
= 'bad parentheses wrapping'
627 self
.errors
.append(Error(msg
, entry
.parentheses_stack
[-1]))
629 def check_line_start(self
):
630 for entry
in self
.changelog_entries
:
631 for line
in entry
.lines
:
632 if line
.startswith('\t '):
633 msg
= 'extra space after tab'
634 self
.errors
.append(Error(msg
, line
))
636 def get_file_changelog_location(self
, changelog_file
):
637 for file in self
.info
.modified_files
:
638 if file[0] == changelog_file
:
639 # root ChangeLog file
641 index
= file[0].find('/' + changelog_file
)
643 return file[0][:index
]
646 def deduce_changelog_locations(self
):
647 for entry
in self
.changelog_entries
:
648 if entry
.folder
is None:
650 for file in entry
.files
:
651 location
= self
.get_file_changelog_location(file)
653 or (location
and location
in self
.changelog_locations
)):
654 if changelog
and changelog
!= location
:
655 msg
= 'could not deduce ChangeLog file, ' \
656 'not unique location'
657 self
.errors
.append(Error(msg
))
660 if changelog
is not None:
661 entry
.folder
= changelog
663 msg
= 'could not deduce ChangeLog file'
664 self
.errors
.append(Error(msg
))
667 def in_ignored_location(cls
, path
):
668 for ignored
in ignored_prefixes
:
669 if path
.startswith(ignored
):
673 def get_changelog_by_path(self
, path
):
674 components
= path
.split('/')
676 if '/'.join(components
) in self
.changelog_locations
:
678 components
= components
[:-1]
679 return '/'.join(components
)
681 def check_mentioned_files(self
):
682 folder_count
= len([x
.folder
for x
in self
.changelog_entries
])
683 assert folder_count
== len(self
.changelog_entries
)
685 mentioned_files
= set()
686 mentioned_patterns
= []
687 used_patterns
= set()
688 for entry
in self
.changelog_entries
:
689 if not entry
.files
and not entry
.file_patterns
:
690 msg
= 'no files mentioned for ChangeLog in directory'
691 self
.errors
.append(Error(msg
, entry
.folder
))
692 assert not entry
.folder
.endswith('/')
693 for file in entry
.files
:
694 if not self
.is_changelog_filename(file):
695 item
= os
.path
.join(entry
.folder
, file)
696 if item
in mentioned_files
:
697 msg
= 'same file specified multiple times'
698 self
.errors
.append(Error(msg
, file))
700 mentioned_files
.add(item
)
701 for pattern
in entry
.file_patterns
:
702 mentioned_patterns
.append(os
.path
.join(entry
.folder
, pattern
))
704 cand
= [x
[0] for x
in self
.info
.modified_files
705 if not self
.is_changelog_filename(x
[0])]
706 changed_files
= set(cand
)
707 for file in sorted(mentioned_files
- changed_files
):
708 msg
= 'unchanged file mentioned in a ChangeLog'
709 candidates
= difflib
.get_close_matches(file, changed_files
, 1)
712 msg
+= f
' (did you mean "{candidates[0]}"?)'
713 details
= '\n'.join(difflib
.Differ().compare([file], [candidates
[0]])).rstrip()
714 self
.errors
.append(Error(msg
, file, details
))
715 auto_add_warnings
= defaultdict(list)
716 for file in sorted(changed_files
- mentioned_files
):
717 if not self
.in_ignored_location(file):
718 if file in self
.new_files
:
719 changelog_location
= self
.get_changelog_by_path(file)
720 # Python2: we cannot use next(filter(...))
721 entries
= filter(lambda x
: x
.folder
== changelog_location
,
722 self
.changelog_entries
)
723 entries
= list(entries
)
724 entry
= entries
[0] if entries
else None
726 prs
= self
.top_level_prs
728 # if all ChangeLog entries have identical PRs
730 if self
.changelog_entries
:
731 prs
= self
.changelog_entries
[0].prs
732 for entry
in self
.changelog_entries
:
736 entry
= ChangeLogEntry(changelog_location
,
737 self
.top_level_authors
,
739 self
.changelog_entries
.append(entry
)
740 # strip prefix of the file
741 assert file.startswith(entry
.folder
)
742 # do not allow auto-addition of New files
743 # for the top-level folder
745 file = file[len(entry
.folder
):].lstrip('/')
746 entry
.lines
.append('\t* %s: New file.' % file)
747 entry
.files
.append(file)
748 auto_add_warnings
[entry
.folder
].append(file)
750 msg
= 'new file in the top-level folder not mentioned in a ChangeLog'
751 self
.errors
.append(Error(msg
, file))
753 used_pattern
= [p
for p
in mentioned_patterns
754 if file.startswith(p
)]
755 used_pattern
= used_pattern
[0] if used_pattern
else None
757 used_patterns
.add(used_pattern
)
759 msg
= 'changed file not mentioned in a ChangeLog'
760 self
.errors
.append(Error(msg
, file))
762 for pattern
in mentioned_patterns
:
763 if pattern
not in used_patterns
:
764 error
= "pattern doesn't match any changed files"
765 self
.errors
.append(Error(error
, pattern
))
766 for entry
, val
in auto_add_warnings
.items():
768 self
.warnings
.append(f
"Auto-added new file '{entry}/{val[0]}'")
770 self
.warnings
.append(f
"Auto-added {len(val)} new files in '{entry}'")
772 def check_for_correct_changelog(self
):
773 for entry
in self
.changelog_entries
:
774 for file in entry
.files
:
775 full_path
= os
.path
.join(entry
.folder
, file)
776 changelog_location
= self
.get_changelog_by_path(full_path
)
777 if changelog_location
!= entry
.folder
:
778 msg
= 'wrong ChangeLog location "%s", should be "%s"'
779 err
= Error(msg
% (entry
.folder
, changelog_location
), file)
780 self
.errors
.append(err
)
783 def format_authors_in_changelog(cls
, authors
, timestamp
, prefix
=''):
785 for i
, author
in enumerate(authors
):
787 output
+= '%s%s %s\n' % (prefix
, timestamp
, author
)
789 output
+= '%s\t %s\n' % (prefix
, author
)
793 def to_changelog_entries(self
, use_commit_ts
=False):
794 current_timestamp
= self
.info
.date
.strftime(DATE_FORMAT
)
795 for entry
in self
.changelog_entries
:
797 timestamp
= entry
.datetime
798 if self
.revert_commit
:
799 timestamp
= current_timestamp
800 orig_date
= self
.original_info
.date
801 current_timestamp
= orig_date
.strftime(DATE_FORMAT
)
802 elif self
.cherry_pick_commit
:
803 info
= (self
.commit_to_info_hook
804 and self
.commit_to_info_hook(self
.cherry_pick_commit
))
805 # it can happen that it is a cherry-pick for a different
808 timestamp
= info
.date
.strftime(DATE_FORMAT
)
810 if self
.commit_to_info_hook
:
811 self
.warnings
.append(f
"Cherry-picked commit not found: '{self.cherry_pick_commit}'")
813 self
.warnings
.append(f
"Invoked script can not obtain info about "
814 f
"cherry-picked commits such as '{self.revert_commit}'")
815 timestamp
= current_timestamp
816 elif not timestamp
or use_commit_ts
:
817 timestamp
= current_timestamp
818 authors
= entry
.authors
if entry
.authors
else [self
.info
.author
]
819 # add Co-Authored-By authors to all ChangeLog entries
820 for author
in self
.co_authors
:
821 if author
not in authors
:
822 authors
.append(author
)
824 if self
.cherry_pick_commit
or self
.revert_commit
:
825 original_author
= self
.original_info
.author
826 output
+= self
.format_authors_in_changelog([original_author
],
828 if self
.revert_commit
:
829 output
+= '\tRevert:\n'
831 output
+= '\tBackported from master:\n'
832 output
+= self
.format_authors_in_changelog(authors
,
835 output
+= self
.format_authors_in_changelog(authors
, timestamp
)
837 output
+= '\t%s\n' % pr
838 for line
in entry
.lines
:
839 output
+= line
+ '\n'
840 yield (entry
.folder
, output
.rstrip())
842 def print_output(self
):
843 for entry
, output
in self
.to_changelog_entries():
844 print('------ %s/ChangeLog ------ ' % entry
)
847 def print_errors(self
):
849 for error
in self
.errors
:
852 def print_warnings(self
):
855 for warning
in self
.warnings
:
858 def check_commit_email(self
):
859 # Parse 'Martin Liska <mliska@suse.cz>'
860 email
= self
.info
.author
.split(' ')[-1].strip('<>')
862 # Verify that all characters are ASCII
863 # TODO: Python 3.7 provides a nicer function: isascii
864 if len(email
) != len(email
.encode()):
865 self
.errors
.append(Error(f
'non-ASCII characters in git commit email address ({email})'))