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
19import difflib
20import os
21import re
22import sys
23
24default_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
81bug_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
132ignored_prefixes = {
133    'gcc/d/dmd/',
134    'gcc/go/gofrontend/',
135    'gcc/testsuite/gdc.test/',
136    'gcc/testsuite/go.test/test/',
137    'libgo/',
138    'libphobos/libdruntime/',
139    'libphobos/src/',
140    'libsanitizer/',
141    }
142
143wildcard_prefixes = {
144    'gcc/testsuite/',
145    'libstdc++-v3/doc/html/',
146    'libstdc++-v3/testsuite/'
147    }
148
149misc_files = {
150    'gcc/DATESTAMP',
151    'gcc/BASE-VER',
152    'gcc/DEV-PHASE'
153    }
154
155author_line_regex = \
156        re.compile(r'^(?P<datetime>\d{4}-\d{2}-\d{2})\ {2}(?P<name>.*  <.*>)')
157additional_author_regex = re.compile(r'^\t(?P<spaces>\ *)?(?P<name>.*  <.*>)')
158changelog_regex = re.compile(r'^(?:[fF]or +)?([a-z0-9+-/]*)ChangeLog:?')
159pr_regex = re.compile(r'\tPR (?P<component>[a-z+-]+\/)?([0-9]+)$')
160dr_regex = re.compile(r'\tDR ([0-9]+)$')
161star_prefix_regex = re.compile(r'\t\*(?P<spaces>\ *)(?P<content>.*)')
162end_of_location_regex = re.compile(r'[\[<(:]')
163item_empty_regex = re.compile(r'\t(\* \S+ )?\(\S+\):\s*$')
164item_parenthesis_regex = re.compile(r'\t(\*|\(\S+\):)')
165revert_regex = re.compile(r'This reverts commit (?P<hash>\w+).$')
166cherry_pick_regex = re.compile(r'cherry picked from commit (?P<hash>\w+)')
167
168LINE_LIMIT = 100
169TAB_WIDTH = 8
170CO_AUTHORED_BY_PREFIX = 'co-authored-by: '
171
172REVIEW_PREFIXES = ('reviewed-by: ', 'reviewed-on: ', 'signed-off-by: ',
173                   'acked-by: ', 'tested-by: ', 'reported-by: ',
174                   'suggested-by: ')
175DATE_FORMAT = '%Y-%m-%d'
176
177
178def decode_path(path):
179    # When core.quotepath is true (default value), utf8 chars are encoded like:
180    # "b/ko\304\215ka.txt"
181    #
182    # The upstream bug is fixed:
183    # https://github.com/gitpython-developers/GitPython/issues/1099
184    #
185    # but we still need a workaround for older versions of the library.
186    # Please take a look at the explanation of the transformation:
187    # https://stackoverflow.com/questions/990169/how-do-convert-unicode-escape-sequences-to-unicode-characters-in-a-python-string
188
189    if path.startswith('"') and path.endswith('"'):
190        return (path.strip('"').encode('utf8').decode('unicode-escape')
191                .encode('latin-1').decode('utf8'))
192    else:
193        return path
194
195
196class Error:
197    def __init__(self, message, line=None):
198        self.message = message
199        self.line = line
200
201    def __repr__(self):
202        s = self.message
203        if self.line:
204            s += ': "%s"' % self.line
205        return s
206
207
208class ChangeLogEntry:
209    def __init__(self, folder, authors, prs):
210        self.folder = folder
211        # The 'list.copy()' function is not available before Python 3.3
212        self.author_lines = list(authors)
213        self.initial_prs = list(prs)
214        self.prs = list(prs)
215        self.lines = []
216        self.files = []
217        self.file_patterns = []
218        self.opened_parentheses = 0
219
220    def parse_file_names(self):
221        # Whether the content currently processed is between a star prefix the
222        # end of the file list: a colon or an open paren.
223        in_location = False
224
225        for line in self.lines:
226            # If this line matches the star prefix, start the location
227            # processing on the information that follows the star.
228            # Note that we need to skip macro names that can be in form of:
229            #
230            # * config/i386/i386.md (*fix_trunc<mode>_i387_1,
231            # *add<mode>3_ne, *add<mode>3_eq_0, *add<mode>3_ne_0,
232            # *fist<mode>2_<rounding>_1, *<code><mode>3_1):
233            #
234            m = star_prefix_regex.match(line)
235            if m and len(m.group('spaces')) == 1:
236                in_location = True
237                line = m.group('content')
238
239            if in_location:
240                # Strip everything that is not a filename in "line":
241                # entities "(NAME)", cases "<PATTERN>", conditions
242                # "[COND]", entry text (the colon, if present, and
243                # anything that follows it).
244                m = end_of_location_regex.search(line)
245                if m:
246                    line = line[:m.start()]
247                    in_location = False
248
249                # At this point, all that's left is a list of filenames
250                # separated by commas and whitespaces.
251                for file in line.split(','):
252                    file = file.strip()
253                    if file:
254                        if file.endswith('*'):
255                            self.file_patterns.append(file[:-1])
256                        else:
257                            self.files.append(file)
258
259    @property
260    def datetime(self):
261        for author in self.author_lines:
262            if author[1]:
263                return author[1]
264        return None
265
266    @property
267    def authors(self):
268        return [author_line[0] for author_line in self.author_lines]
269
270    @property
271    def is_empty(self):
272        return not self.lines and self.prs == self.initial_prs
273
274    def contains_author(self, author):
275        for author_lines in self.author_lines:
276            if author_lines[0] == author:
277                return True
278        return False
279
280
281class GitInfo:
282    def __init__(self, hexsha, date, author, lines, modified_files):
283        self.hexsha = hexsha
284        self.date = date
285        self.author = author
286        self.lines = lines
287        self.modified_files = modified_files
288
289
290class GitCommit:
291    def __init__(self, info, commit_to_info_hook=None, ref_name=None):
292        self.original_info = info
293        self.info = info
294        self.message = None
295        self.changes = None
296        self.changelog_entries = []
297        self.errors = []
298        self.top_level_authors = []
299        self.co_authors = []
300        self.top_level_prs = []
301        self.cherry_pick_commit = None
302        self.revert_commit = None
303        self.commit_to_info_hook = commit_to_info_hook
304        self.init_changelog_locations(ref_name)
305
306        # Skip Update copyright years commits
307        if self.info.lines and self.info.lines[0] == 'Update copyright years.':
308            return
309
310        # Identify first if the commit is a Revert commit
311        for line in self.info.lines:
312            m = revert_regex.match(line)
313            if m:
314                self.revert_commit = m.group('hash')
315                break
316        if self.revert_commit:
317            self.info = self.commit_to_info_hook(self.revert_commit)
318
319        # Allow complete deletion of ChangeLog files in a commit
320        project_files = [f for f in self.info.modified_files
321                         if (self.is_changelog_filename(f[0], allow_suffix=True) and f[1] != 'D')
322                         or f[0] in misc_files]
323        ignored_files = [f for f in self.info.modified_files
324                         if self.in_ignored_location(f[0])]
325        if len(project_files) == len(self.info.modified_files):
326            # All modified files are only MISC files
327            return
328        elif project_files:
329            self.errors.append(Error('ChangeLog, DATESTAMP, BASE-VER and '
330                                     'DEV-PHASE updates should be done '
331                                     'separately from normal commits'))
332            return
333
334        all_are_ignored = (len(project_files) + len(ignored_files)
335                           == len(self.info.modified_files))
336        self.parse_lines(all_are_ignored)
337        if self.changes:
338            self.parse_changelog()
339            self.parse_file_names()
340            self.check_for_empty_description()
341            self.check_for_broken_parentheses()
342            self.deduce_changelog_locations()
343            self.check_file_patterns()
344            if not self.errors:
345                self.check_mentioned_files()
346                self.check_for_correct_changelog()
347
348    @property
349    def success(self):
350        return not self.errors
351
352    @property
353    def new_files(self):
354        return [x[0] for x in self.info.modified_files if x[1] == 'A']
355
356    @classmethod
357    def is_changelog_filename(cls, path, allow_suffix=False):
358        basename = os.path.basename(path)
359        if basename == 'ChangeLog':
360            return True
361        elif allow_suffix and basename.startswith('ChangeLog'):
362            return True
363        else:
364            return False
365
366    def find_changelog_location(self, name):
367        if name.startswith('\t'):
368            name = name[1:]
369        if name.endswith(':'):
370            name = name[:-1]
371        if name.endswith('/'):
372            name = name[:-1]
373        return name if name in self.changelog_locations else None
374
375    @classmethod
376    def format_git_author(cls, author):
377        assert '<' in author
378        return author.replace('<', ' <')
379
380    @classmethod
381    def parse_git_name_status(cls, string):
382        modified_files = []
383        for entry in string.split('\n'):
384            parts = entry.split('\t')
385            t = parts[0]
386            if t == 'A' or t == 'D' or t == 'M':
387                modified_files.append((parts[1], t))
388            elif t.startswith('R'):
389                modified_files.append((parts[1], 'D'))
390                modified_files.append((parts[2], 'A'))
391        return modified_files
392
393    def init_changelog_locations(self, ref_name):
394        self.changelog_locations = list(default_changelog_locations)
395        if ref_name:
396            version = sys.maxsize
397            if 'releases/gcc-' in ref_name:
398                version = int(ref_name.split('-')[-1])
399            if version >= 12:
400                # HSA and BRIG were removed in GCC 12
401                self.changelog_locations.remove('gcc/brig')
402                self.changelog_locations.remove('libhsail-rt')
403
404    def parse_lines(self, all_are_ignored):
405        body = self.info.lines
406
407        for i, b in enumerate(body):
408            if not b:
409                continue
410            if (changelog_regex.match(b) or self.find_changelog_location(b)
411                    or star_prefix_regex.match(b) or pr_regex.match(b)
412                    or dr_regex.match(b) or author_line_regex.match(b)
413                    or b.lower().startswith(CO_AUTHORED_BY_PREFIX)):
414                self.changes = body[i:]
415                return
416        if not all_are_ignored:
417            self.errors.append(Error('cannot find a ChangeLog location in '
418                                     'message'))
419
420    def parse_changelog(self):
421        last_entry = None
422        will_deduce = False
423        for line in self.changes:
424            if not line:
425                if last_entry and will_deduce:
426                    last_entry = None
427                continue
428            if line != line.rstrip():
429                self.errors.append(Error('trailing whitespace', line))
430            if len(line.replace('\t', ' ' * TAB_WIDTH)) > LINE_LIMIT:
431                # support long filenames
432                if not line.startswith('\t* ') or not line.endswith(':') or ' ' in line[3:-1]:
433                    self.errors.append(Error('line exceeds %d character limit'
434                                             % LINE_LIMIT, line))
435            m = changelog_regex.match(line)
436            if m:
437                last_entry = ChangeLogEntry(m.group(1).rstrip('/'),
438                                            self.top_level_authors,
439                                            self.top_level_prs)
440                self.changelog_entries.append(last_entry)
441            elif self.find_changelog_location(line):
442                last_entry = ChangeLogEntry(self.find_changelog_location(line),
443                                            self.top_level_authors,
444                                            self.top_level_prs)
445                self.changelog_entries.append(last_entry)
446            else:
447                author_tuple = None
448                pr_line = None
449                if author_line_regex.match(line):
450                    m = author_line_regex.match(line)
451                    author_tuple = (m.group('name'), m.group('datetime'))
452                elif additional_author_regex.match(line):
453                    m = additional_author_regex.match(line)
454                    if len(m.group('spaces')) != 4:
455                        msg = 'additional author must be indented with '\
456                              'one tab and four spaces'
457                        self.errors.append(Error(msg, line))
458                    else:
459                        author_tuple = (m.group('name'), None)
460                elif pr_regex.match(line):
461                    component = pr_regex.match(line).group('component')
462                    if not component:
463                        self.errors.append(Error('missing PR component', line))
464                        continue
465                    elif not component[:-1] in bug_components:
466                        self.errors.append(Error('invalid PR component', line))
467                        continue
468                    else:
469                        pr_line = line.lstrip()
470                elif dr_regex.match(line):
471                    pr_line = line.lstrip()
472
473                lowered_line = line.lower()
474                if lowered_line.startswith(CO_AUTHORED_BY_PREFIX):
475                    name = line[len(CO_AUTHORED_BY_PREFIX):]
476                    author = self.format_git_author(name)
477                    self.co_authors.append(author)
478                    continue
479                elif lowered_line.startswith(REVIEW_PREFIXES):
480                    continue
481                else:
482                    m = cherry_pick_regex.search(line)
483                    if m:
484                        commit = m.group('hash')
485                        if self.cherry_pick_commit:
486                            msg = 'multiple cherry pick lines'
487                            self.errors.append(Error(msg, line))
488                        else:
489                            self.cherry_pick_commit = commit
490                        continue
491
492                # ChangeLog name will be deduced later
493                if not last_entry:
494                    if author_tuple:
495                        self.top_level_authors.append(author_tuple)
496                        continue
497                    elif pr_line:
498                        # append to top_level_prs only when we haven't met
499                        # a ChangeLog entry
500                        if (pr_line not in self.top_level_prs
501                                and not self.changelog_entries):
502                            self.top_level_prs.append(pr_line)
503                        continue
504                    else:
505                        last_entry = ChangeLogEntry(None,
506                                                    self.top_level_authors,
507                                                    self.top_level_prs)
508                        self.changelog_entries.append(last_entry)
509                        will_deduce = True
510                elif author_tuple:
511                    if not last_entry.contains_author(author_tuple[0]):
512                        last_entry.author_lines.append(author_tuple)
513                    continue
514
515                if not line.startswith('\t'):
516                    err = Error('line should start with a tab', line)
517                    self.errors.append(err)
518                elif pr_line:
519                    last_entry.prs.append(pr_line)
520                else:
521                    m = star_prefix_regex.match(line)
522                    if m:
523                        if (len(m.group('spaces')) != 1 and
524                                last_entry.opened_parentheses == 0):
525                            msg = 'one space should follow asterisk'
526                            self.errors.append(Error(msg, line))
527                        else:
528                            content = m.group('content')
529                            parts = content.split(':')
530                            if len(parts) > 1:
531                                for needle in ('()', '[]', '<>'):
532                                    if ' ' + needle in parts[0]:
533                                        msg = f'empty group "{needle}" found'
534                                        self.errors.append(Error(msg, line))
535                            last_entry.lines.append(line)
536                            self.process_parentheses(last_entry, line)
537                    else:
538                        if last_entry.is_empty:
539                            msg = 'first line should start with a tab, ' \
540                                  'an asterisk and a space'
541                            self.errors.append(Error(msg, line))
542                        else:
543                            last_entry.lines.append(line)
544                            self.process_parentheses(last_entry, line)
545
546    def process_parentheses(self, last_entry, line):
547        for c in line:
548            if c == '(':
549                last_entry.opened_parentheses += 1
550            elif c == ')':
551                if last_entry.opened_parentheses == 0:
552                    msg = 'bad wrapping of parenthesis'
553                    self.errors.append(Error(msg, line))
554                else:
555                    last_entry.opened_parentheses -= 1
556
557    def parse_file_names(self):
558        for entry in self.changelog_entries:
559            entry.parse_file_names()
560
561    def check_file_patterns(self):
562        for entry in self.changelog_entries:
563            for pattern in entry.file_patterns:
564                name = os.path.join(entry.folder, pattern)
565                if not [name.startswith(pr) for pr in wildcard_prefixes]:
566                    msg = 'unsupported wildcard prefix'
567                    self.errors.append(Error(msg, name))
568
569    def check_for_empty_description(self):
570        for entry in self.changelog_entries:
571            for i, line in enumerate(entry.lines):
572                if (item_empty_regex.match(line) and
573                    (i == len(entry.lines) - 1
574                     or not entry.lines[i+1].strip()
575                     or item_parenthesis_regex.match(entry.lines[i+1]))):
576                    msg = 'missing description of a change'
577                    self.errors.append(Error(msg, line))
578
579    def check_for_broken_parentheses(self):
580        for entry in self.changelog_entries:
581            if entry.opened_parentheses != 0:
582                msg = 'bad parentheses wrapping'
583                self.errors.append(Error(msg, entry.lines[0]))
584
585    def get_file_changelog_location(self, changelog_file):
586        for file in self.info.modified_files:
587            if file[0] == changelog_file:
588                # root ChangeLog file
589                return ''
590            index = file[0].find('/' + changelog_file)
591            if index != -1:
592                return file[0][:index]
593        return None
594
595    def deduce_changelog_locations(self):
596        for entry in self.changelog_entries:
597            if not entry.folder:
598                changelog = None
599                for file in entry.files:
600                    location = self.get_file_changelog_location(file)
601                    if (location == ''
602                       or (location and location in self.changelog_locations)):
603                        if changelog and changelog != location:
604                            msg = 'could not deduce ChangeLog file, ' \
605                                  'not unique location'
606                            self.errors.append(Error(msg))
607                            return
608                        changelog = location
609                if changelog is not None:
610                    entry.folder = changelog
611                else:
612                    msg = 'could not deduce ChangeLog file'
613                    self.errors.append(Error(msg))
614
615    @classmethod
616    def in_ignored_location(cls, path):
617        for ignored in ignored_prefixes:
618            if path.startswith(ignored):
619                return True
620        return False
621
622    def get_changelog_by_path(self, path):
623        components = path.split('/')
624        while components:
625            if '/'.join(components) in self.changelog_locations:
626                break
627            components = components[:-1]
628        return '/'.join(components)
629
630    def check_mentioned_files(self):
631        folder_count = len([x.folder for x in self.changelog_entries])
632        assert folder_count == len(self.changelog_entries)
633
634        mentioned_files = set()
635        mentioned_patterns = []
636        used_patterns = set()
637        for entry in self.changelog_entries:
638            if not entry.files and not entry.file_patterns:
639                msg = 'no files mentioned for ChangeLog in directory'
640                self.errors.append(Error(msg, entry.folder))
641            assert not entry.folder.endswith('/')
642            for file in entry.files:
643                if not self.is_changelog_filename(file):
644                    item = os.path.join(entry.folder, file)
645                    if item in mentioned_files:
646                        msg = 'same file specified multiple times'
647                        self.errors.append(Error(msg, file))
648                    else:
649                        mentioned_files.add(item)
650            for pattern in entry.file_patterns:
651                mentioned_patterns.append(os.path.join(entry.folder, pattern))
652
653        cand = [x[0] for x in self.info.modified_files
654                if not self.is_changelog_filename(x[0])]
655        changed_files = set(cand)
656        for file in sorted(mentioned_files - changed_files):
657            msg = 'unchanged file mentioned in a ChangeLog'
658            candidates = difflib.get_close_matches(file, changed_files, 1)
659            if candidates:
660                msg += f' (did you mean "{candidates[0]}"?)'
661            self.errors.append(Error(msg, file))
662        for file in sorted(changed_files - mentioned_files):
663            if not self.in_ignored_location(file):
664                if file in self.new_files:
665                    changelog_location = self.get_changelog_by_path(file)
666                    # Python2: we cannot use next(filter(...))
667                    entries = filter(lambda x: x.folder == changelog_location,
668                                     self.changelog_entries)
669                    entries = list(entries)
670                    entry = entries[0] if entries else None
671                    if not entry:
672                        prs = self.top_level_prs
673                        if not prs:
674                            # if all ChangeLog entries have identical PRs
675                            # then use them
676                            prs = self.changelog_entries[0].prs
677                            for entry in self.changelog_entries:
678                                if entry.prs != prs:
679                                    prs = []
680                                    break
681                        entry = ChangeLogEntry(changelog_location,
682                                               self.top_level_authors,
683                                               prs)
684                        self.changelog_entries.append(entry)
685                    # strip prefix of the file
686                    assert file.startswith(entry.folder)
687                    file = file[len(entry.folder):].lstrip('/')
688                    entry.lines.append('\t* %s: New file.' % file)
689                    entry.files.append(file)
690                else:
691                    used_pattern = [p for p in mentioned_patterns
692                                    if file.startswith(p)]
693                    used_pattern = used_pattern[0] if used_pattern else None
694                    if used_pattern:
695                        used_patterns.add(used_pattern)
696                    else:
697                        msg = 'changed file not mentioned in a ChangeLog'
698                        self.errors.append(Error(msg, file))
699
700        for pattern in mentioned_patterns:
701            if pattern not in used_patterns:
702                error = "pattern doesn't match any changed files"
703                self.errors.append(Error(error, pattern))
704
705    def check_for_correct_changelog(self):
706        for entry in self.changelog_entries:
707            for file in entry.files:
708                full_path = os.path.join(entry.folder, file)
709                changelog_location = self.get_changelog_by_path(full_path)
710                if changelog_location != entry.folder:
711                    msg = 'wrong ChangeLog location "%s", should be "%s"'
712                    err = Error(msg % (entry.folder, changelog_location), file)
713                    self.errors.append(err)
714
715    @classmethod
716    def format_authors_in_changelog(cls, authors, timestamp, prefix=''):
717        output = ''
718        for i, author in enumerate(authors):
719            if i == 0:
720                output += '%s%s  %s\n' % (prefix, timestamp, author)
721            else:
722                output += '%s\t    %s\n' % (prefix, author)
723        output += '\n'
724        return output
725
726    def to_changelog_entries(self, use_commit_ts=False):
727        current_timestamp = self.info.date.strftime(DATE_FORMAT)
728        for entry in self.changelog_entries:
729            output = ''
730            timestamp = entry.datetime
731            if self.revert_commit:
732                timestamp = current_timestamp
733                orig_date = self.original_info.date
734                current_timestamp = orig_date.strftime(DATE_FORMAT)
735            elif self.cherry_pick_commit:
736                info = self.commit_to_info_hook(self.cherry_pick_commit)
737                # it can happen that it is a cherry-pick for a different
738                # repository
739                if info:
740                    timestamp = info.date.strftime(DATE_FORMAT)
741                else:
742                    timestamp = current_timestamp
743            elif not timestamp or use_commit_ts:
744                timestamp = current_timestamp
745            authors = entry.authors if entry.authors else [self.info.author]
746            # add Co-Authored-By authors to all ChangeLog entries
747            for author in self.co_authors:
748                if author not in authors:
749                    authors.append(author)
750
751            if self.cherry_pick_commit or self.revert_commit:
752                original_author = self.original_info.author
753                output += self.format_authors_in_changelog([original_author],
754                                                           current_timestamp)
755                if self.revert_commit:
756                    output += '\tRevert:\n'
757                else:
758                    output += '\tBackported from master:\n'
759                output += self.format_authors_in_changelog(authors,
760                                                           timestamp, '\t')
761            else:
762                output += self.format_authors_in_changelog(authors, timestamp)
763            for pr in entry.prs:
764                output += '\t%s\n' % pr
765            for line in entry.lines:
766                output += line + '\n'
767            yield (entry.folder, output.rstrip())
768
769    def print_output(self):
770        for entry, output in self.to_changelog_entries():
771            print('------ %s/ChangeLog ------ ' % entry)
772            print(output)
773
774    def print_errors(self):
775        print('Errors:')
776        for error in self.errors:
777            print(error)
778