blob: 57440bbb3214fc1e8890212b822ecc8fee1b01d1 [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 Peskine6e97c432020-03-27 19:05:18 +010065STANDARD_CATEGORIES = (
66 b'API changes',
Gilles Peskine40b3f412019-10-13 21:44:25 +020067 b'Default behavior changes',
68 b'Requirement changes',
69 b'New deprecations',
70 b'Removals',
Gilles Peskine6e97c432020-03-27 19:05:18 +010071 b'Features',
Gilles Peskine40b3f412019-10-13 21:44:25 +020072 b'Security',
Gilles Peskine6e97c432020-03-27 19:05:18 +010073 b'Bugfix',
74 b'Changes',
Gilles Peskine40b3f412019-10-13 21:44:25 +020075)
76
Gilles Peskine6e97c432020-03-27 19:05:18 +010077CategoryContent = namedtuple('CategoryContent', [
78 'name', 'title_line', # Title text and line number of the title
79 'body', 'body_line', # Body text and starting line number of the body
80])
81
82class ChangelogFormat:
83 """Virtual class documenting how to write a changelog format class."""
84
85 @classmethod
86 def extract_top_version(cls, changelog_file_content):
87 """Split out the top version section.
88
89 Return ``(header, top_version_title, top_version_body, trailer)``
90 where ``changelog_file_content == header + top_version_title +
91 top_version_body + trailer``.
Gilles Peskineeebf24f2020-03-27 19:25:38 +010092
93 If the top version is already released, create a new top
94 version section for an unreleased version.
Gilles Peskine6e97c432020-03-27 19:05:18 +010095 """
96 raise NotImplementedError
97
98 @classmethod
99 def version_title_text(cls, version_title):
100 """Return the text of a formatted version section title."""
101 raise NotImplementedError
102
103 @classmethod
104 def split_categories(cls, version_body):
105 """Split a changelog version section body into categories.
106
107 Return a list of `CategoryContent` the name is category title
108 without any formatting.
109 """
110 raise NotImplementedError
111
112 @classmethod
113 def format_category(cls, title, body):
114 """Construct the text of a category section from its title and body."""
115 raise NotImplementedError
116
117class TextChangelogFormat(ChangelogFormat):
118 """The traditional Mbed TLS changelog format."""
119
Gilles Peskineeebf24f2020-03-27 19:25:38 +0100120 _unreleased_version_text = b'= mbed TLS x.x.x branch released xxxx-xx-xx'
121 @classmethod
122 def is_released_version(cls, title):
123 # Look for an incomplete release date
124 return not re.search(br'[0-9x]{4}-[0-9x]{2}-[0-9x]?x', title)
125
Gilles Peskine6e97c432020-03-27 19:05:18 +0100126 _top_version_re = re.compile(br'(?:\A|\n)(=[^\n]*\n+)(.*?\n)(?:=|$)',
127 re.DOTALL)
128 @classmethod
129 def extract_top_version(cls, changelog_file_content):
130 """A version section starts with a line starting with '='."""
131 m = re.search(cls._top_version_re, changelog_file_content)
132 top_version_start = m.start(1)
133 top_version_end = m.end(2)
Gilles Peskineeebf24f2020-03-27 19:25:38 +0100134 top_version_title = m.group(1)
135 top_version_body = m.group(2)
136 if cls.is_released_version(top_version_title):
137 top_version_end = top_version_start
138 top_version_title = cls._unreleased_version_text + b'\n\n'
139 top_version_body = b''
Gilles Peskine6e97c432020-03-27 19:05:18 +0100140 return (changelog_file_content[:top_version_start],
Gilles Peskineeebf24f2020-03-27 19:25:38 +0100141 top_version_title, top_version_body,
Gilles Peskine6e97c432020-03-27 19:05:18 +0100142 changelog_file_content[top_version_end:])
143
144 @classmethod
145 def version_title_text(cls, version_title):
146 return re.sub(br'\n.*', version_title, re.DOTALL)
147
148 _category_title_re = re.compile(br'(^\w.*)\n+', re.MULTILINE)
149 @classmethod
150 def split_categories(cls, version_body):
151 """A category title is a line with the title in column 0."""
Gilles Peskine4d977a42020-03-27 19:42:50 +0100152 if not version_body:
Gilles Peskine6e97c432020-03-27 19:05:18 +0100153 return []
Gilles Peskine4d977a42020-03-27 19:42:50 +0100154 title_matches = list(re.finditer(cls._category_title_re, version_body))
155 if not title_matches or title_matches[0].start() != 0:
156 # There is junk before the first category.
157 raise CategoryParseError(0, 'Junk found where category expected')
Gilles Peskine6e97c432020-03-27 19:05:18 +0100158 title_starts = [m.start(1) for m in title_matches]
159 body_starts = [m.end(0) for m in title_matches]
160 body_ends = title_starts[1:] + [len(version_body)]
161 bodies = [version_body[body_start:body_end].rstrip(b'\n') + b'\n'
162 for (body_start, body_end) in zip(body_starts, body_ends)]
163 title_lines = [version_body[:pos].count(b'\n') for pos in title_starts]
164 body_lines = [version_body[:pos].count(b'\n') for pos in body_starts]
165 return [CategoryContent(title_match.group(1), title_line,
166 body, body_line)
167 for title_match, title_line, body, body_line
168 in zip(title_matches, title_lines, bodies, body_lines)]
169
170 @classmethod
171 def format_category(cls, title, body):
172 # `split_categories` ensures that each body ends with a newline.
173 # Make sure that there is additionally a blank line between categories.
174 if not body.endswith(b'\n\n'):
175 body += b'\n'
176 return title + b'\n' + body
177
Gilles Peskine40b3f412019-10-13 21:44:25 +0200178class ChangeLog:
Gilles Peskine42f384c2020-03-27 09:23:38 +0100179 """An Mbed TLS changelog.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200180
Gilles Peskine6e97c432020-03-27 19:05:18 +0100181 A changelog file consists of some header text followed by one or
182 more version sections. The version sections are in reverse
183 chronological order. Each version section consists of a title and a body.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200184
Gilles Peskine6e97c432020-03-27 19:05:18 +0100185 The body of a version section consists of zero or more category
186 subsections. Each category subsection consists of a title and a body.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200187
Gilles Peskine6e97c432020-03-27 19:05:18 +0100188 A changelog entry file has the same format as the body of a version section.
189
190 A `ChangelogFormat` object defines the concrete syntax of the changelog.
191 Entry files must have the same format as the changelog file.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200192 """
193
Gilles Peskinea2607962020-01-28 19:58:17 +0100194 # Only accept dotted version numbers (e.g. "3.1", not "3").
Gilles Peskineafc9db82020-01-30 11:38:01 +0100195 # Refuse ".x" in a version number where x is a letter: this indicates
196 # a version that is not yet released. Something like "3.1a" is accepted.
197 _version_number_re = re.compile(br'[0-9]+\.[0-9A-Za-z.]+')
198 _incomplete_version_number_re = re.compile(br'.*\.[A-Za-z]')
Gilles Peskinea2607962020-01-28 19:58:17 +0100199
Gilles Peskine6e97c432020-03-27 19:05:18 +0100200 def add_categories_from_text(self, filename, line_offset,
201 text, allow_unknown_category):
202 """Parse a version section or entry file."""
Gilles Peskine4d977a42020-03-27 19:42:50 +0100203 try:
204 categories = self.format.split_categories(text)
205 except CategoryParseError as e:
206 raise InputFormatError(filename, line_offset + e.line_offset,
207 e.error_message)
Gilles Peskine6e97c432020-03-27 19:05:18 +0100208 for category in categories:
209 if not allow_unknown_category and \
210 category.name not in self.categories:
211 raise InputFormatError(filename,
212 line_offset + category.title_line,
213 'Unknown category: "{}"',
214 category.name.decode('utf8'))
215 self.categories[category.name] += category.body
216
217 def __init__(self, input_stream, changelog_format):
Gilles Peskine40b3f412019-10-13 21:44:25 +0200218 """Create a changelog object.
219
Gilles Peskine974232f2020-01-22 12:43:29 +0100220 Populate the changelog object from the content of the file
Gilles Peskine6e97c432020-03-27 19:05:18 +0100221 input_stream.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200222 """
Gilles Peskine6e97c432020-03-27 19:05:18 +0100223 self.format = changelog_format
224 whole_file = input_stream.read()
225 (self.header,
226 self.top_version_title, top_version_body,
227 self.trailer) = self.format.extract_top_version(whole_file)
228 # Split the top version section into categories.
229 self.categories = OrderedDict()
230 for category in STANDARD_CATEGORIES:
231 self.categories[category] = b''
Gilles Peskinee248e832020-03-27 19:42:38 +0100232 offset = (self.header + self.top_version_title).count(b'\n') + 1
Gilles Peskine6e97c432020-03-27 19:05:18 +0100233 self.add_categories_from_text(input_stream.name, offset,
234 top_version_body, True)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200235
236 def add_file(self, input_stream):
237 """Add changelog entries from a file.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200238 """
Gilles Peskinee248e832020-03-27 19:42:38 +0100239 self.add_categories_from_text(input_stream.name, 1,
Gilles Peskine6e97c432020-03-27 19:05:18 +0100240 input_stream.read(), False)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200241
242 def write(self, filename):
243 """Write the changelog to the specified file.
244 """
245 with open(filename, 'wb') as out:
Gilles Peskine6e97c432020-03-27 19:05:18 +0100246 out.write(self.header)
247 out.write(self.top_version_title)
248 for title, body in self.categories.items():
249 if not body:
Gilles Peskine40b3f412019-10-13 21:44:25 +0200250 continue
Gilles Peskine6e97c432020-03-27 19:05:18 +0100251 out.write(self.format.format_category(title, body))
252 out.write(self.trailer)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200253
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100254
255@functools.total_ordering
Gilles Peskine28af9582020-03-26 22:39:18 +0100256class EntryFileSortKey:
257 """This classes defines an ordering on changelog entry files: older < newer.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100258
Gilles Peskine28af9582020-03-26 22:39:18 +0100259 * Merged entry files are sorted according to their merge date (date of
260 the merge commit that brought the commit that created the file into
261 the target branch).
262 * Committed but unmerged entry files are sorted according to the date
263 of the commit that adds them.
264 * Uncommitted entry files are sorted according to their modification time.
265
266 This class assumes that the file is in a git working directory with
267 the target branch checked out.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100268 """
269
270 # Categories of files. A lower number is considered older.
271 MERGED = 0
272 COMMITTED = 1
273 LOCAL = 2
274
275 @staticmethod
276 def creation_hash(filename):
277 """Return the git commit id at which the given file was created.
278
279 Return None if the file was never checked into git.
280 """
Gilles Peskine98a53aa2020-03-26 22:47:07 +0100281 hashes = subprocess.check_output(['git', 'log', '--format=%H',
282 '--follow',
283 '--', filename])
Gilles Peskine13dc6342020-03-26 22:46:47 +0100284 m = re.search(b'(.+)$', hashes)
285 if not m:
286 # The git output is empty. This means that the file was
287 # never checked in.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100288 return None
Gilles Peskine13dc6342020-03-26 22:46:47 +0100289 # The last commit in the log is the oldest one, which is when the
290 # file was created.
291 return m.group(0)
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100292
293 @staticmethod
294 def list_merges(some_hash, target, *options):
295 """List merge commits from some_hash to target.
296
297 Pass options to git to select which commits are included.
298 """
299 text = subprocess.check_output(['git', 'rev-list',
300 '--merges', *options,
301 b'..'.join([some_hash, target])])
302 return text.rstrip(b'\n').split(b'\n')
303
304 @classmethod
305 def merge_hash(cls, some_hash):
306 """Return the git commit id at which the given commit was merged.
307
308 Return None if the given commit was never merged.
309 """
310 target = b'HEAD'
311 # List the merges from some_hash to the target in two ways.
312 # The ancestry list is the ones that are both descendants of
313 # some_hash and ancestors of the target.
314 ancestry = frozenset(cls.list_merges(some_hash, target,
315 '--ancestry-path'))
316 # The first_parents list only contains merges that are directly
317 # on the target branch. We want it in reverse order (oldest first).
318 first_parents = cls.list_merges(some_hash, target,
319 '--first-parent', '--reverse')
320 # Look for the oldest merge commit that's both on the direct path
321 # and directly on the target branch. That's the place where some_hash
322 # was merged on the target branch. See
323 # https://stackoverflow.com/questions/8475448/find-merge-commit-which-include-a-specific-commit
324 for commit in first_parents:
325 if commit in ancestry:
326 return commit
327 return None
328
329 @staticmethod
330 def commit_timestamp(commit_id):
Gilles Peskineac0f0862020-03-27 10:56:45 +0100331 """Return the timestamp of the given commit."""
332 text = subprocess.check_output(['git', 'show', '-s',
333 '--format=%ct',
334 commit_id])
335 return datetime.datetime.utcfromtimestamp(int(text))
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100336
337 @staticmethod
338 def file_timestamp(filename):
339 """Return the modification timestamp of the given file."""
340 mtime = os.stat(filename).st_mtime
341 return datetime.datetime.fromtimestamp(mtime)
342
343 def __init__(self, filename):
Gilles Peskine28af9582020-03-26 22:39:18 +0100344 """Determine position of the file in the changelog entry order.
345
346 This constructor returns an object that can be used with comparison
347 operators, with `sort` and `sorted`, etc. Older entries are sorted
348 before newer entries.
349 """
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100350 self.filename = filename
351 creation_hash = self.creation_hash(filename)
352 if not creation_hash:
353 self.category = self.LOCAL
354 self.datetime = self.file_timestamp(filename)
355 return
356 merge_hash = self.merge_hash(creation_hash)
357 if not merge_hash:
358 self.category = self.COMMITTED
359 self.datetime = self.commit_timestamp(creation_hash)
360 return
361 self.category = self.MERGED
362 self.datetime = self.commit_timestamp(merge_hash)
363
364 def sort_key(self):
Gilles Peskine28af9582020-03-26 22:39:18 +0100365 """"Return a concrete sort key for this entry file sort key object.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100366
Gilles Peskine28af9582020-03-26 22:39:18 +0100367 ``ts1 < ts2`` is implemented as ``ts1.sort_key() < ts2.sort_key()``.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100368 """
369 return (self.category, self.datetime, self.filename)
370
371 def __eq__(self, other):
372 return self.sort_key() == other.sort_key()
373
374 def __lt__(self, other):
375 return self.sort_key() < other.sort_key()
376
377
Gilles Peskine2b242492020-01-22 15:41:50 +0100378def check_output(generated_output_file, main_input_file, merged_files):
379 """Make sanity checks on the generated output.
380
381 The intent of these sanity checks is to have reasonable confidence
382 that no content has been lost.
383
384 The sanity check is that every line that is present in an input file
385 is also present in an output file. This is not perfect but good enough
386 for now.
387 """
388 generated_output = set(open(generated_output_file, 'rb'))
389 for line in open(main_input_file, 'rb'):
390 if line not in generated_output:
391 raise LostContent('original file', line)
392 for merged_file in merged_files:
393 for line in open(merged_file, 'rb'):
394 if line not in generated_output:
395 raise LostContent(merged_file, line)
396
397def finish_output(changelog, output_file, input_file, merged_files):
Gilles Peskine40b3f412019-10-13 21:44:25 +0200398 """Write the changelog to the output file.
399
Gilles Peskine2b242492020-01-22 15:41:50 +0100400 The input file and the list of merged files are used only for sanity
401 checks on the output.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200402 """
403 if os.path.exists(output_file) and not os.path.isfile(output_file):
404 # The output is a non-regular file (e.g. pipe). Write to it directly.
405 output_temp = output_file
406 else:
407 # The output is a regular file. Write to a temporary file,
408 # then move it into place atomically.
409 output_temp = output_file + '.tmp'
410 changelog.write(output_temp)
Gilles Peskine2b242492020-01-22 15:41:50 +0100411 check_output(output_temp, input_file, merged_files)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200412 if output_temp != output_file:
413 os.rename(output_temp, output_file)
414
Gilles Peskine5e39c9e2020-01-22 14:55:37 +0100415def remove_merged_entries(files_to_remove):
416 for filename in files_to_remove:
417 os.remove(filename)
418
Gilles Peskine27a1fac2020-03-25 16:34:18 +0100419def list_files_to_merge(options):
420 """List the entry files to merge, oldest first.
421
Gilles Peskine28af9582020-03-26 22:39:18 +0100422 "Oldest" is defined by `EntryFileSortKey`.
Gilles Peskine27a1fac2020-03-25 16:34:18 +0100423 """
Gilles Peskine6e97c432020-03-27 19:05:18 +0100424 files_to_merge = glob.glob(os.path.join(options.dir, '*.txt'))
Gilles Peskine7fa3eb72020-03-26 22:41:32 +0100425 files_to_merge.sort(key=EntryFileSortKey)
Gilles Peskine27a1fac2020-03-25 16:34:18 +0100426 return files_to_merge
427
Gilles Peskine40b3f412019-10-13 21:44:25 +0200428def merge_entries(options):
429 """Merge changelog entries into the changelog file.
430
431 Read the changelog file from options.input.
432 Read entries to merge from the directory options.dir.
433 Write the new changelog to options.output.
434 Remove the merged entries if options.keep_entries is false.
435 """
436 with open(options.input, 'rb') as input_file:
Gilles Peskine6e97c432020-03-27 19:05:18 +0100437 changelog = ChangeLog(input_file, TextChangelogFormat)
Gilles Peskine27a1fac2020-03-25 16:34:18 +0100438 files_to_merge = list_files_to_merge(options)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200439 if not files_to_merge:
440 sys.stderr.write('There are no pending changelog entries.\n')
441 return
442 for filename in files_to_merge:
443 with open(filename, 'rb') as input_file:
444 changelog.add_file(input_file)
Gilles Peskine2b242492020-01-22 15:41:50 +0100445 finish_output(changelog, options.output, options.input, files_to_merge)
Gilles Peskine5e39c9e2020-01-22 14:55:37 +0100446 if not options.keep_entries:
447 remove_merged_entries(files_to_merge)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200448
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100449def show_file_timestamps(options):
450 """List the files to merge and their timestamp.
451
452 This is only intended for debugging purposes.
453 """
454 files = list_files_to_merge(options)
455 for filename in files:
Gilles Peskine28af9582020-03-26 22:39:18 +0100456 ts = EntryFileSortKey(filename)
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100457 print(ts.category, ts.datetime, filename)
458
Gilles Peskine40b3f412019-10-13 21:44:25 +0200459def set_defaults(options):
460 """Add default values for missing options."""
461 output_file = getattr(options, 'output', None)
462 if output_file is None:
463 options.output = options.input
464 if getattr(options, 'keep_entries', None) is None:
465 options.keep_entries = (output_file is not None)
466
467def main():
468 """Command line entry point."""
469 parser = argparse.ArgumentParser(description=__doc__)
470 parser.add_argument('--dir', '-d', metavar='DIR',
471 default='ChangeLog.d',
Gilles Peskine6e910092020-01-22 15:58:18 +0100472 help='Directory to read entries from'
473 ' (default: ChangeLog.d)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200474 parser.add_argument('--input', '-i', metavar='FILE',
Gilles Peskine6e97c432020-03-27 19:05:18 +0100475 default='ChangeLog',
Gilles Peskine6e910092020-01-22 15:58:18 +0100476 help='Existing changelog file to read from and augment'
Gilles Peskine6e97c432020-03-27 19:05:18 +0100477 ' (default: ChangeLog)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200478 parser.add_argument('--keep-entries',
479 action='store_true', dest='keep_entries', default=None,
Gilles Peskine6e910092020-01-22 15:58:18 +0100480 help='Keep the files containing entries'
481 ' (default: remove them if --output/-o is not specified)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200482 parser.add_argument('--no-keep-entries',
483 action='store_false', dest='keep_entries',
Gilles Peskine6e910092020-01-22 15:58:18 +0100484 help='Remove the files containing entries after they are merged'
485 ' (default: remove them if --output/-o is not specified)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200486 parser.add_argument('--output', '-o', metavar='FILE',
Gilles Peskine6e910092020-01-22 15:58:18 +0100487 help='Output changelog file'
488 ' (default: overwrite the input)')
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100489 parser.add_argument('--list-files-only',
490 action='store_true',
Gilles Peskinec68c7c82020-03-27 19:01:35 +0100491 help=('Only list the files that would be processed '
Gilles Peskineac0f0862020-03-27 10:56:45 +0100492 '(with some debugging information)'))
Gilles Peskine40b3f412019-10-13 21:44:25 +0200493 options = parser.parse_args()
494 set_defaults(options)
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100495 if options.list_files_only:
496 show_file_timestamps(options)
497 return
Gilles Peskine40b3f412019-10-13 21:44:25 +0200498 merge_entries(options)
499
500if __name__ == '__main__':
501 main()