blob: ffa3f161b8ec680088bc7ef394430571ed6ae6c1 [file] [log] [blame]
Gilles Peskine40b3f412019-10-13 21:44:25 +02001#!/usr/bin/env python3
2
Gilles Peskine42f384c2020-03-27 09:23:38 +01003"""Assemble Mbed TLS change log entries into the change log file.
Gilles Peskinea2607962020-01-28 19:58:17 +01004
5Add changelog entries to the first level-2 section.
6Create a new level-2 section for unreleased changes if needed.
7Remove the input files unless --keep-entries is specified.
Gilles Peskine28af9582020-03-26 22:39:18 +01008
9In each level-3 section, entries are sorted in chronological order
10(oldest first). From oldest to newest:
11* Merged entry files are sorted according to their merge date (date of
12 the merge commit that brought the commit that created the file into
13 the target branch).
14* Committed but unmerged entry files are sorted according to the date
15 of the commit that adds them.
16* Uncommitted entry files are sorted according to their modification time.
17
18You must run this program from within a git working directory.
Gilles Peskine40b3f412019-10-13 21:44:25 +020019"""
20
21# Copyright (C) 2019, Arm Limited, All Rights Reserved
22# SPDX-License-Identifier: Apache-2.0
23#
24# Licensed under the Apache License, Version 2.0 (the "License"); you may
25# not use this file except in compliance with the License.
26# You may obtain a copy of the License at
27#
28# http://www.apache.org/licenses/LICENSE-2.0
29#
30# Unless required by applicable law or agreed to in writing, software
31# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
32# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
33# See the License for the specific language governing permissions and
34# limitations under the License.
35#
Gilles Peskine42f384c2020-03-27 09:23:38 +010036# This file is part of Mbed TLS (https://tls.mbed.org)
Gilles Peskine40b3f412019-10-13 21:44:25 +020037
38import argparse
Gilles Peskine6e97c432020-03-27 19:05:18 +010039from collections import OrderedDict, namedtuple
Gilles Peskine8f46bbf2020-03-25 16:34:43 +010040import datetime
41import functools
Gilles Peskine40b3f412019-10-13 21:44:25 +020042import glob
43import os
44import re
Gilles Peskine8f46bbf2020-03-25 16:34:43 +010045import subprocess
Gilles Peskine40b3f412019-10-13 21:44:25 +020046import sys
47
48class InputFormatError(Exception):
49 def __init__(self, filename, line_number, message, *args, **kwargs):
Gilles Peskine566407d2020-01-22 15:55:36 +010050 message = '{}:{}: {}'.format(filename, line_number,
51 message.format(*args, **kwargs))
52 super().__init__(message)
Gilles Peskine40b3f412019-10-13 21:44:25 +020053
Gilles Peskine4d977a42020-03-27 19:42:50 +010054class CategoryParseError(Exception):
55 def __init__(self, line_offset, error_message):
56 self.line_offset = line_offset
57 self.error_message = error_message
58 super().__init__('{}: {}'.format(line_offset, error_message))
59
Gilles Peskine2b242492020-01-22 15:41:50 +010060class LostContent(Exception):
61 def __init__(self, filename, line):
62 message = ('Lost content from {}: "{}"'.format(filename, line))
63 super().__init__(message)
64
Gilles Peskineb695d5e2020-03-27 20:06:12 +010065# The category names we use in the changelog.
66# If you edit this, update ChangeLog.d/README.md.
Gilles Peskine6e97c432020-03-27 19:05:18 +010067STANDARD_CATEGORIES = (
68 b'API changes',
Gilles Peskine40b3f412019-10-13 21:44:25 +020069 b'Default behavior changes',
70 b'Requirement changes',
71 b'New deprecations',
72 b'Removals',
Gilles Peskine6e97c432020-03-27 19:05:18 +010073 b'Features',
Gilles Peskine40b3f412019-10-13 21:44:25 +020074 b'Security',
Gilles Peskine6e97c432020-03-27 19:05:18 +010075 b'Bugfix',
76 b'Changes',
Gilles Peskine40b3f412019-10-13 21:44:25 +020077)
78
Gilles Peskine6e97c432020-03-27 19:05:18 +010079CategoryContent = namedtuple('CategoryContent', [
80 'name', 'title_line', # Title text and line number of the title
81 'body', 'body_line', # Body text and starting line number of the body
82])
83
84class ChangelogFormat:
85 """Virtual class documenting how to write a changelog format class."""
86
87 @classmethod
88 def extract_top_version(cls, changelog_file_content):
89 """Split out the top version section.
90
Gilles Peskineeebf24f2020-03-27 19:25:38 +010091 If the top version is already released, create a new top
92 version section for an unreleased version.
Gilles Peskinedba4de02020-03-30 11:37:26 +020093
94 Return ``(header, top_version_title, top_version_body, trailer)``
95 where the "top version" is the existing top version section if it's
96 for unreleased changes, and a newly created section otherwise.
97 To assemble the changelog after modifying top_version_body,
98 concatenate the four pieces.
Gilles Peskine6e97c432020-03-27 19:05:18 +010099 """
100 raise NotImplementedError
101
102 @classmethod
103 def version_title_text(cls, version_title):
104 """Return the text of a formatted version section title."""
105 raise NotImplementedError
106
107 @classmethod
108 def split_categories(cls, version_body):
109 """Split a changelog version section body into categories.
110
111 Return a list of `CategoryContent` the name is category title
112 without any formatting.
113 """
114 raise NotImplementedError
115
116 @classmethod
117 def format_category(cls, title, body):
118 """Construct the text of a category section from its title and body."""
119 raise NotImplementedError
120
121class TextChangelogFormat(ChangelogFormat):
122 """The traditional Mbed TLS changelog format."""
123
Gilles Peskineeebf24f2020-03-27 19:25:38 +0100124 _unreleased_version_text = b'= mbed TLS x.x.x branch released xxxx-xx-xx'
125 @classmethod
126 def is_released_version(cls, title):
127 # Look for an incomplete release date
128 return not re.search(br'[0-9x]{4}-[0-9x]{2}-[0-9x]?x', title)
129
Gilles Peskine6e97c432020-03-27 19:05:18 +0100130 _top_version_re = re.compile(br'(?:\A|\n)(=[^\n]*\n+)(.*?\n)(?:=|$)',
131 re.DOTALL)
132 @classmethod
133 def extract_top_version(cls, changelog_file_content):
134 """A version section starts with a line starting with '='."""
135 m = re.search(cls._top_version_re, changelog_file_content)
136 top_version_start = m.start(1)
137 top_version_end = m.end(2)
Gilles Peskineeebf24f2020-03-27 19:25:38 +0100138 top_version_title = m.group(1)
139 top_version_body = m.group(2)
140 if cls.is_released_version(top_version_title):
141 top_version_end = top_version_start
142 top_version_title = cls._unreleased_version_text + b'\n\n'
143 top_version_body = b''
Gilles Peskine6e97c432020-03-27 19:05:18 +0100144 return (changelog_file_content[:top_version_start],
Gilles Peskineeebf24f2020-03-27 19:25:38 +0100145 top_version_title, top_version_body,
Gilles Peskine6e97c432020-03-27 19:05:18 +0100146 changelog_file_content[top_version_end:])
147
148 @classmethod
149 def version_title_text(cls, version_title):
150 return re.sub(br'\n.*', version_title, re.DOTALL)
151
152 _category_title_re = re.compile(br'(^\w.*)\n+', re.MULTILINE)
153 @classmethod
154 def split_categories(cls, version_body):
155 """A category title is a line with the title in column 0."""
Gilles Peskine4d977a42020-03-27 19:42:50 +0100156 if not version_body:
Gilles Peskine6e97c432020-03-27 19:05:18 +0100157 return []
Gilles Peskine4d977a42020-03-27 19:42:50 +0100158 title_matches = list(re.finditer(cls._category_title_re, version_body))
159 if not title_matches or title_matches[0].start() != 0:
160 # There is junk before the first category.
161 raise CategoryParseError(0, 'Junk found where category expected')
Gilles Peskine6e97c432020-03-27 19:05:18 +0100162 title_starts = [m.start(1) for m in title_matches]
163 body_starts = [m.end(0) for m in title_matches]
164 body_ends = title_starts[1:] + [len(version_body)]
165 bodies = [version_body[body_start:body_end].rstrip(b'\n') + b'\n'
166 for (body_start, body_end) in zip(body_starts, body_ends)]
167 title_lines = [version_body[:pos].count(b'\n') for pos in title_starts]
168 body_lines = [version_body[:pos].count(b'\n') for pos in body_starts]
169 return [CategoryContent(title_match.group(1), title_line,
170 body, body_line)
171 for title_match, title_line, body, body_line
172 in zip(title_matches, title_lines, bodies, body_lines)]
173
174 @classmethod
175 def format_category(cls, title, body):
176 # `split_categories` ensures that each body ends with a newline.
177 # Make sure that there is additionally a blank line between categories.
178 if not body.endswith(b'\n\n'):
179 body += b'\n'
180 return title + b'\n' + body
181
Gilles Peskine40b3f412019-10-13 21:44:25 +0200182class ChangeLog:
Gilles Peskine42f384c2020-03-27 09:23:38 +0100183 """An Mbed TLS changelog.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200184
Gilles Peskine6e97c432020-03-27 19:05:18 +0100185 A changelog file consists of some header text followed by one or
186 more version sections. The version sections are in reverse
187 chronological order. Each version section consists of a title and a body.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200188
Gilles Peskine6e97c432020-03-27 19:05:18 +0100189 The body of a version section consists of zero or more category
190 subsections. Each category subsection consists of a title and a body.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200191
Gilles Peskine6e97c432020-03-27 19:05:18 +0100192 A changelog entry file has the same format as the body of a version section.
193
194 A `ChangelogFormat` object defines the concrete syntax of the changelog.
195 Entry files must have the same format as the changelog file.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200196 """
197
Gilles Peskinea2607962020-01-28 19:58:17 +0100198 # Only accept dotted version numbers (e.g. "3.1", not "3").
Gilles Peskineafc9db82020-01-30 11:38:01 +0100199 # Refuse ".x" in a version number where x is a letter: this indicates
200 # a version that is not yet released. Something like "3.1a" is accepted.
201 _version_number_re = re.compile(br'[0-9]+\.[0-9A-Za-z.]+')
202 _incomplete_version_number_re = re.compile(br'.*\.[A-Za-z]')
Gilles Peskinea2607962020-01-28 19:58:17 +0100203
Gilles Peskine6e97c432020-03-27 19:05:18 +0100204 def add_categories_from_text(self, filename, line_offset,
205 text, allow_unknown_category):
206 """Parse a version section or entry file."""
Gilles Peskine4d977a42020-03-27 19:42:50 +0100207 try:
208 categories = self.format.split_categories(text)
209 except CategoryParseError as e:
210 raise InputFormatError(filename, line_offset + e.line_offset,
211 e.error_message)
Gilles Peskine6e97c432020-03-27 19:05:18 +0100212 for category in categories:
213 if not allow_unknown_category and \
214 category.name not in self.categories:
215 raise InputFormatError(filename,
216 line_offset + category.title_line,
217 'Unknown category: "{}"',
218 category.name.decode('utf8'))
219 self.categories[category.name] += category.body
220
221 def __init__(self, input_stream, changelog_format):
Gilles Peskine40b3f412019-10-13 21:44:25 +0200222 """Create a changelog object.
223
Gilles Peskine974232f2020-01-22 12:43:29 +0100224 Populate the changelog object from the content of the file
Gilles Peskine6e97c432020-03-27 19:05:18 +0100225 input_stream.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200226 """
Gilles Peskine6e97c432020-03-27 19:05:18 +0100227 self.format = changelog_format
228 whole_file = input_stream.read()
229 (self.header,
230 self.top_version_title, top_version_body,
231 self.trailer) = self.format.extract_top_version(whole_file)
232 # Split the top version section into categories.
233 self.categories = OrderedDict()
234 for category in STANDARD_CATEGORIES:
235 self.categories[category] = b''
Gilles Peskinee248e832020-03-27 19:42:38 +0100236 offset = (self.header + self.top_version_title).count(b'\n') + 1
Gilles Peskine6e97c432020-03-27 19:05:18 +0100237 self.add_categories_from_text(input_stream.name, offset,
238 top_version_body, True)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200239
240 def add_file(self, input_stream):
241 """Add changelog entries from a file.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200242 """
Gilles Peskinee248e832020-03-27 19:42:38 +0100243 self.add_categories_from_text(input_stream.name, 1,
Gilles Peskine6e97c432020-03-27 19:05:18 +0100244 input_stream.read(), False)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200245
246 def write(self, filename):
247 """Write the changelog to the specified file.
248 """
249 with open(filename, 'wb') as out:
Gilles Peskine6e97c432020-03-27 19:05:18 +0100250 out.write(self.header)
251 out.write(self.top_version_title)
252 for title, body in self.categories.items():
253 if not body:
Gilles Peskine40b3f412019-10-13 21:44:25 +0200254 continue
Gilles Peskine6e97c432020-03-27 19:05:18 +0100255 out.write(self.format.format_category(title, body))
256 out.write(self.trailer)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200257
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100258
259@functools.total_ordering
Gilles Peskine28af9582020-03-26 22:39:18 +0100260class EntryFileSortKey:
261 """This classes defines an ordering on changelog entry files: older < newer.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100262
Gilles Peskine28af9582020-03-26 22:39:18 +0100263 * Merged entry files are sorted according to their merge date (date of
264 the merge commit that brought the commit that created the file into
265 the target branch).
266 * Committed but unmerged entry files are sorted according to the date
267 of the commit that adds them.
268 * Uncommitted entry files are sorted according to their modification time.
269
270 This class assumes that the file is in a git working directory with
271 the target branch checked out.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100272 """
273
274 # Categories of files. A lower number is considered older.
275 MERGED = 0
276 COMMITTED = 1
277 LOCAL = 2
278
279 @staticmethod
280 def creation_hash(filename):
281 """Return the git commit id at which the given file was created.
282
283 Return None if the file was never checked into git.
284 """
Gilles Peskine98a53aa2020-03-26 22:47:07 +0100285 hashes = subprocess.check_output(['git', 'log', '--format=%H',
286 '--follow',
287 '--', filename])
Gilles Peskine13dc6342020-03-26 22:46:47 +0100288 m = re.search(b'(.+)$', hashes)
289 if not m:
290 # The git output is empty. This means that the file was
291 # never checked in.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100292 return None
Gilles Peskine13dc6342020-03-26 22:46:47 +0100293 # The last commit in the log is the oldest one, which is when the
294 # file was created.
295 return m.group(0)
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100296
297 @staticmethod
298 def list_merges(some_hash, target, *options):
299 """List merge commits from some_hash to target.
300
301 Pass options to git to select which commits are included.
302 """
303 text = subprocess.check_output(['git', 'rev-list',
304 '--merges', *options,
305 b'..'.join([some_hash, target])])
306 return text.rstrip(b'\n').split(b'\n')
307
308 @classmethod
309 def merge_hash(cls, some_hash):
310 """Return the git commit id at which the given commit was merged.
311
312 Return None if the given commit was never merged.
313 """
314 target = b'HEAD'
315 # List the merges from some_hash to the target in two ways.
316 # The ancestry list is the ones that are both descendants of
317 # some_hash and ancestors of the target.
318 ancestry = frozenset(cls.list_merges(some_hash, target,
319 '--ancestry-path'))
320 # The first_parents list only contains merges that are directly
321 # on the target branch. We want it in reverse order (oldest first).
322 first_parents = cls.list_merges(some_hash, target,
323 '--first-parent', '--reverse')
324 # Look for the oldest merge commit that's both on the direct path
325 # and directly on the target branch. That's the place where some_hash
326 # was merged on the target branch. See
327 # https://stackoverflow.com/questions/8475448/find-merge-commit-which-include-a-specific-commit
328 for commit in first_parents:
329 if commit in ancestry:
330 return commit
331 return None
332
333 @staticmethod
334 def commit_timestamp(commit_id):
Gilles Peskineac0f0862020-03-27 10:56:45 +0100335 """Return the timestamp of the given commit."""
336 text = subprocess.check_output(['git', 'show', '-s',
337 '--format=%ct',
338 commit_id])
339 return datetime.datetime.utcfromtimestamp(int(text))
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100340
341 @staticmethod
342 def file_timestamp(filename):
343 """Return the modification timestamp of the given file."""
344 mtime = os.stat(filename).st_mtime
345 return datetime.datetime.fromtimestamp(mtime)
346
347 def __init__(self, filename):
Gilles Peskine28af9582020-03-26 22:39:18 +0100348 """Determine position of the file in the changelog entry order.
349
350 This constructor returns an object that can be used with comparison
351 operators, with `sort` and `sorted`, etc. Older entries are sorted
352 before newer entries.
353 """
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100354 self.filename = filename
355 creation_hash = self.creation_hash(filename)
356 if not creation_hash:
357 self.category = self.LOCAL
358 self.datetime = self.file_timestamp(filename)
359 return
360 merge_hash = self.merge_hash(creation_hash)
361 if not merge_hash:
362 self.category = self.COMMITTED
363 self.datetime = self.commit_timestamp(creation_hash)
364 return
365 self.category = self.MERGED
366 self.datetime = self.commit_timestamp(merge_hash)
367
368 def sort_key(self):
Gilles Peskine28af9582020-03-26 22:39:18 +0100369 """"Return a concrete sort key for this entry file sort key object.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100370
Gilles Peskine28af9582020-03-26 22:39:18 +0100371 ``ts1 < ts2`` is implemented as ``ts1.sort_key() < ts2.sort_key()``.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100372 """
373 return (self.category, self.datetime, self.filename)
374
375 def __eq__(self, other):
376 return self.sort_key() == other.sort_key()
377
378 def __lt__(self, other):
379 return self.sort_key() < other.sort_key()
380
381
Gilles Peskine2b242492020-01-22 15:41:50 +0100382def check_output(generated_output_file, main_input_file, merged_files):
383 """Make sanity checks on the generated output.
384
385 The intent of these sanity checks is to have reasonable confidence
386 that no content has been lost.
387
388 The sanity check is that every line that is present in an input file
389 is also present in an output file. This is not perfect but good enough
390 for now.
391 """
392 generated_output = set(open(generated_output_file, 'rb'))
393 for line in open(main_input_file, 'rb'):
394 if line not in generated_output:
395 raise LostContent('original file', line)
396 for merged_file in merged_files:
397 for line in open(merged_file, 'rb'):
398 if line not in generated_output:
399 raise LostContent(merged_file, line)
400
401def finish_output(changelog, output_file, input_file, merged_files):
Gilles Peskine40b3f412019-10-13 21:44:25 +0200402 """Write the changelog to the output file.
403
Gilles Peskine2b242492020-01-22 15:41:50 +0100404 The input file and the list of merged files are used only for sanity
405 checks on the output.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200406 """
407 if os.path.exists(output_file) and not os.path.isfile(output_file):
408 # The output is a non-regular file (e.g. pipe). Write to it directly.
409 output_temp = output_file
410 else:
411 # The output is a regular file. Write to a temporary file,
412 # then move it into place atomically.
413 output_temp = output_file + '.tmp'
414 changelog.write(output_temp)
Gilles Peskine2b242492020-01-22 15:41:50 +0100415 check_output(output_temp, input_file, merged_files)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200416 if output_temp != output_file:
417 os.rename(output_temp, output_file)
418
Gilles Peskine5e39c9e2020-01-22 14:55:37 +0100419def remove_merged_entries(files_to_remove):
420 for filename in files_to_remove:
421 os.remove(filename)
422
Gilles Peskine27a1fac2020-03-25 16:34:18 +0100423def list_files_to_merge(options):
424 """List the entry files to merge, oldest first.
425
Gilles Peskine28af9582020-03-26 22:39:18 +0100426 "Oldest" is defined by `EntryFileSortKey`.
Gilles Peskine27a1fac2020-03-25 16:34:18 +0100427 """
Gilles Peskine6e97c432020-03-27 19:05:18 +0100428 files_to_merge = glob.glob(os.path.join(options.dir, '*.txt'))
Gilles Peskine7fa3eb72020-03-26 22:41:32 +0100429 files_to_merge.sort(key=EntryFileSortKey)
Gilles Peskine27a1fac2020-03-25 16:34:18 +0100430 return files_to_merge
431
Gilles Peskine40b3f412019-10-13 21:44:25 +0200432def merge_entries(options):
433 """Merge changelog entries into the changelog file.
434
435 Read the changelog file from options.input.
436 Read entries to merge from the directory options.dir.
437 Write the new changelog to options.output.
438 Remove the merged entries if options.keep_entries is false.
439 """
440 with open(options.input, 'rb') as input_file:
Gilles Peskine6e97c432020-03-27 19:05:18 +0100441 changelog = ChangeLog(input_file, TextChangelogFormat)
Gilles Peskine27a1fac2020-03-25 16:34:18 +0100442 files_to_merge = list_files_to_merge(options)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200443 if not files_to_merge:
444 sys.stderr.write('There are no pending changelog entries.\n')
445 return
446 for filename in files_to_merge:
447 with open(filename, 'rb') as input_file:
448 changelog.add_file(input_file)
Gilles Peskine2b242492020-01-22 15:41:50 +0100449 finish_output(changelog, options.output, options.input, files_to_merge)
Gilles Peskine5e39c9e2020-01-22 14:55:37 +0100450 if not options.keep_entries:
451 remove_merged_entries(files_to_merge)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200452
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100453def show_file_timestamps(options):
454 """List the files to merge and their timestamp.
455
456 This is only intended for debugging purposes.
457 """
458 files = list_files_to_merge(options)
459 for filename in files:
Gilles Peskine28af9582020-03-26 22:39:18 +0100460 ts = EntryFileSortKey(filename)
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100461 print(ts.category, ts.datetime, filename)
462
Gilles Peskine40b3f412019-10-13 21:44:25 +0200463def set_defaults(options):
464 """Add default values for missing options."""
465 output_file = getattr(options, 'output', None)
466 if output_file is None:
467 options.output = options.input
468 if getattr(options, 'keep_entries', None) is None:
469 options.keep_entries = (output_file is not None)
470
471def main():
472 """Command line entry point."""
473 parser = argparse.ArgumentParser(description=__doc__)
474 parser.add_argument('--dir', '-d', metavar='DIR',
475 default='ChangeLog.d',
Gilles Peskine6e910092020-01-22 15:58:18 +0100476 help='Directory to read entries from'
477 ' (default: ChangeLog.d)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200478 parser.add_argument('--input', '-i', metavar='FILE',
Gilles Peskine6e97c432020-03-27 19:05:18 +0100479 default='ChangeLog',
Gilles Peskine6e910092020-01-22 15:58:18 +0100480 help='Existing changelog file to read from and augment'
Gilles Peskine6e97c432020-03-27 19:05:18 +0100481 ' (default: ChangeLog)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200482 parser.add_argument('--keep-entries',
483 action='store_true', dest='keep_entries', default=None,
Gilles Peskine6e910092020-01-22 15:58:18 +0100484 help='Keep the files containing entries'
485 ' (default: remove them if --output/-o is not specified)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200486 parser.add_argument('--no-keep-entries',
487 action='store_false', dest='keep_entries',
Gilles Peskine6e910092020-01-22 15:58:18 +0100488 help='Remove the files containing entries after they are merged'
489 ' (default: remove them if --output/-o is not specified)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200490 parser.add_argument('--output', '-o', metavar='FILE',
Gilles Peskine6e910092020-01-22 15:58:18 +0100491 help='Output changelog file'
492 ' (default: overwrite the input)')
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100493 parser.add_argument('--list-files-only',
494 action='store_true',
Gilles Peskinec68c7c82020-03-27 19:01:35 +0100495 help=('Only list the files that would be processed '
Gilles Peskineac0f0862020-03-27 10:56:45 +0100496 '(with some debugging information)'))
Gilles Peskine40b3f412019-10-13 21:44:25 +0200497 options = parser.parse_args()
498 set_defaults(options)
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100499 if options.list_files_only:
500 show_file_timestamps(options)
501 return
Gilles Peskine40b3f412019-10-13 21:44:25 +0200502 merge_entries(options)
503
504if __name__ == '__main__':
505 main()