blob: 8f7d1fdf9c449927cb271fc28f4f92bc63fd2218 [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
Bence Szépkúti1e148272020-08-07 13:07:28 +020021# Copyright The Mbed TLS Contributors
Gilles Peskine40b3f412019-10-13 21:44:25 +020022# 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.
Gilles Peskine40b3f412019-10-13 21:44:25 +020035
36import argparse
Gilles Peskine6e97c432020-03-27 19:05:18 +010037from collections import OrderedDict, namedtuple
Gilles Peskine8f46bbf2020-03-25 16:34:43 +010038import datetime
39import functools
Gilles Peskine40b3f412019-10-13 21:44:25 +020040import glob
41import os
42import re
Gilles Peskine8f46bbf2020-03-25 16:34:43 +010043import subprocess
Gilles Peskine40b3f412019-10-13 21:44:25 +020044import sys
45
46class InputFormatError(Exception):
47 def __init__(self, filename, line_number, message, *args, **kwargs):
Gilles Peskine566407d2020-01-22 15:55:36 +010048 message = '{}:{}: {}'.format(filename, line_number,
49 message.format(*args, **kwargs))
50 super().__init__(message)
Gilles Peskine40b3f412019-10-13 21:44:25 +020051
Gilles Peskine4d977a42020-03-27 19:42:50 +010052class CategoryParseError(Exception):
53 def __init__(self, line_offset, error_message):
54 self.line_offset = line_offset
55 self.error_message = error_message
56 super().__init__('{}: {}'.format(line_offset, error_message))
57
Gilles Peskine2b242492020-01-22 15:41:50 +010058class LostContent(Exception):
59 def __init__(self, filename, line):
60 message = ('Lost content from {}: "{}"'.format(filename, line))
61 super().__init__(message)
62
Gilles Peskineb695d5e2020-03-27 20:06:12 +010063# The category names we use in the changelog.
64# If you edit this, update ChangeLog.d/README.md.
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
Gilles Peskineeebf24f2020-03-27 19:25:38 +010089 If the top version is already released, create a new top
90 version section for an unreleased version.
Gilles Peskinedba4de02020-03-30 11:37:26 +020091
92 Return ``(header, top_version_title, top_version_body, trailer)``
93 where the "top version" is the existing top version section if it's
94 for unreleased changes, and a newly created section otherwise.
95 To assemble the changelog after modifying top_version_body,
96 concatenate the four pieces.
Gilles Peskine6e97c432020-03-27 19:05:18 +010097 """
98 raise NotImplementedError
99
100 @classmethod
101 def version_title_text(cls, version_title):
102 """Return the text of a formatted version section title."""
103 raise NotImplementedError
104
105 @classmethod
106 def split_categories(cls, version_body):
107 """Split a changelog version section body into categories.
108
109 Return a list of `CategoryContent` the name is category title
110 without any formatting.
111 """
112 raise NotImplementedError
113
114 @classmethod
115 def format_category(cls, title, body):
116 """Construct the text of a category section from its title and body."""
117 raise NotImplementedError
118
119class TextChangelogFormat(ChangelogFormat):
120 """The traditional Mbed TLS changelog format."""
121
Gilles Peskineeebf24f2020-03-27 19:25:38 +0100122 _unreleased_version_text = b'= mbed TLS x.x.x branch released xxxx-xx-xx'
123 @classmethod
124 def is_released_version(cls, title):
125 # Look for an incomplete release date
126 return not re.search(br'[0-9x]{4}-[0-9x]{2}-[0-9x]?x', title)
127
Gilles Peskine6e97c432020-03-27 19:05:18 +0100128 _top_version_re = re.compile(br'(?:\A|\n)(=[^\n]*\n+)(.*?\n)(?:=|$)',
129 re.DOTALL)
130 @classmethod
131 def extract_top_version(cls, changelog_file_content):
132 """A version section starts with a line starting with '='."""
133 m = re.search(cls._top_version_re, changelog_file_content)
134 top_version_start = m.start(1)
135 top_version_end = m.end(2)
Gilles Peskineeebf24f2020-03-27 19:25:38 +0100136 top_version_title = m.group(1)
137 top_version_body = m.group(2)
138 if cls.is_released_version(top_version_title):
139 top_version_end = top_version_start
140 top_version_title = cls._unreleased_version_text + b'\n\n'
141 top_version_body = b''
Gilles Peskine6e97c432020-03-27 19:05:18 +0100142 return (changelog_file_content[:top_version_start],
Gilles Peskineeebf24f2020-03-27 19:25:38 +0100143 top_version_title, top_version_body,
Gilles Peskine6e97c432020-03-27 19:05:18 +0100144 changelog_file_content[top_version_end:])
145
146 @classmethod
147 def version_title_text(cls, version_title):
148 return re.sub(br'\n.*', version_title, re.DOTALL)
149
150 _category_title_re = re.compile(br'(^\w.*)\n+', re.MULTILINE)
151 @classmethod
152 def split_categories(cls, version_body):
153 """A category title is a line with the title in column 0."""
Gilles Peskine4d977a42020-03-27 19:42:50 +0100154 if not version_body:
Gilles Peskine6e97c432020-03-27 19:05:18 +0100155 return []
Gilles Peskine4d977a42020-03-27 19:42:50 +0100156 title_matches = list(re.finditer(cls._category_title_re, version_body))
157 if not title_matches or title_matches[0].start() != 0:
158 # There is junk before the first category.
159 raise CategoryParseError(0, 'Junk found where category expected')
Gilles Peskine6e97c432020-03-27 19:05:18 +0100160 title_starts = [m.start(1) for m in title_matches]
161 body_starts = [m.end(0) for m in title_matches]
162 body_ends = title_starts[1:] + [len(version_body)]
163 bodies = [version_body[body_start:body_end].rstrip(b'\n') + b'\n'
164 for (body_start, body_end) in zip(body_starts, body_ends)]
165 title_lines = [version_body[:pos].count(b'\n') for pos in title_starts]
166 body_lines = [version_body[:pos].count(b'\n') for pos in body_starts]
167 return [CategoryContent(title_match.group(1), title_line,
168 body, body_line)
169 for title_match, title_line, body, body_line
170 in zip(title_matches, title_lines, bodies, body_lines)]
171
172 @classmethod
173 def format_category(cls, title, body):
174 # `split_categories` ensures that each body ends with a newline.
175 # Make sure that there is additionally a blank line between categories.
176 if not body.endswith(b'\n\n'):
177 body += b'\n'
178 return title + b'\n' + body
179
Gilles Peskine40b3f412019-10-13 21:44:25 +0200180class ChangeLog:
Gilles Peskine42f384c2020-03-27 09:23:38 +0100181 """An Mbed TLS changelog.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200182
Gilles Peskine6e97c432020-03-27 19:05:18 +0100183 A changelog file consists of some header text followed by one or
184 more version sections. The version sections are in reverse
185 chronological order. Each version section consists of a title and a body.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200186
Gilles Peskine6e97c432020-03-27 19:05:18 +0100187 The body of a version section consists of zero or more category
188 subsections. Each category subsection consists of a title and a body.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200189
Gilles Peskine6e97c432020-03-27 19:05:18 +0100190 A changelog entry file has the same format as the body of a version section.
191
192 A `ChangelogFormat` object defines the concrete syntax of the changelog.
193 Entry files must have the same format as the changelog file.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200194 """
195
Gilles Peskinea2607962020-01-28 19:58:17 +0100196 # Only accept dotted version numbers (e.g. "3.1", not "3").
Gilles Peskineafc9db82020-01-30 11:38:01 +0100197 # Refuse ".x" in a version number where x is a letter: this indicates
198 # a version that is not yet released. Something like "3.1a" is accepted.
199 _version_number_re = re.compile(br'[0-9]+\.[0-9A-Za-z.]+')
200 _incomplete_version_number_re = re.compile(br'.*\.[A-Za-z]')
Gilles Peskinea2607962020-01-28 19:58:17 +0100201
Gilles Peskine6e97c432020-03-27 19:05:18 +0100202 def add_categories_from_text(self, filename, line_offset,
203 text, allow_unknown_category):
204 """Parse a version section or entry file."""
Gilles Peskine4d977a42020-03-27 19:42:50 +0100205 try:
206 categories = self.format.split_categories(text)
207 except CategoryParseError as e:
208 raise InputFormatError(filename, line_offset + e.line_offset,
209 e.error_message)
Gilles Peskine6e97c432020-03-27 19:05:18 +0100210 for category in categories:
211 if not allow_unknown_category and \
212 category.name not in self.categories:
213 raise InputFormatError(filename,
214 line_offset + category.title_line,
215 'Unknown category: "{}"',
216 category.name.decode('utf8'))
217 self.categories[category.name] += category.body
218
219 def __init__(self, input_stream, changelog_format):
Gilles Peskine40b3f412019-10-13 21:44:25 +0200220 """Create a changelog object.
221
Gilles Peskine974232f2020-01-22 12:43:29 +0100222 Populate the changelog object from the content of the file
Gilles Peskine6e97c432020-03-27 19:05:18 +0100223 input_stream.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200224 """
Gilles Peskine6e97c432020-03-27 19:05:18 +0100225 self.format = changelog_format
226 whole_file = input_stream.read()
227 (self.header,
228 self.top_version_title, top_version_body,
229 self.trailer) = self.format.extract_top_version(whole_file)
230 # Split the top version section into categories.
231 self.categories = OrderedDict()
232 for category in STANDARD_CATEGORIES:
233 self.categories[category] = b''
Gilles Peskinee248e832020-03-27 19:42:38 +0100234 offset = (self.header + self.top_version_title).count(b'\n') + 1
Gilles Peskine6e97c432020-03-27 19:05:18 +0100235 self.add_categories_from_text(input_stream.name, offset,
236 top_version_body, True)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200237
238 def add_file(self, input_stream):
239 """Add changelog entries from a file.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200240 """
Gilles Peskinee248e832020-03-27 19:42:38 +0100241 self.add_categories_from_text(input_stream.name, 1,
Gilles Peskine6e97c432020-03-27 19:05:18 +0100242 input_stream.read(), False)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200243
244 def write(self, filename):
245 """Write the changelog to the specified file.
246 """
247 with open(filename, 'wb') as out:
Gilles Peskine6e97c432020-03-27 19:05:18 +0100248 out.write(self.header)
249 out.write(self.top_version_title)
250 for title, body in self.categories.items():
251 if not body:
Gilles Peskine40b3f412019-10-13 21:44:25 +0200252 continue
Gilles Peskine6e97c432020-03-27 19:05:18 +0100253 out.write(self.format.format_category(title, body))
254 out.write(self.trailer)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200255
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100256
257@functools.total_ordering
Gilles Peskine28af9582020-03-26 22:39:18 +0100258class EntryFileSortKey:
259 """This classes defines an ordering on changelog entry files: older < newer.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100260
Gilles Peskine28af9582020-03-26 22:39:18 +0100261 * Merged entry files are sorted according to their merge date (date of
262 the merge commit that brought the commit that created the file into
263 the target branch).
264 * Committed but unmerged entry files are sorted according to the date
265 of the commit that adds them.
266 * Uncommitted entry files are sorted according to their modification time.
267
268 This class assumes that the file is in a git working directory with
269 the target branch checked out.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100270 """
271
272 # Categories of files. A lower number is considered older.
273 MERGED = 0
274 COMMITTED = 1
275 LOCAL = 2
276
277 @staticmethod
278 def creation_hash(filename):
279 """Return the git commit id at which the given file was created.
280
281 Return None if the file was never checked into git.
282 """
Gilles Peskine98a53aa2020-03-26 22:47:07 +0100283 hashes = subprocess.check_output(['git', 'log', '--format=%H',
284 '--follow',
285 '--', filename])
Gilles Peskine13dc6342020-03-26 22:46:47 +0100286 m = re.search(b'(.+)$', hashes)
287 if not m:
288 # The git output is empty. This means that the file was
289 # never checked in.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100290 return None
Gilles Peskine13dc6342020-03-26 22:46:47 +0100291 # The last commit in the log is the oldest one, which is when the
292 # file was created.
293 return m.group(0)
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100294
295 @staticmethod
296 def list_merges(some_hash, target, *options):
297 """List merge commits from some_hash to target.
298
299 Pass options to git to select which commits are included.
300 """
301 text = subprocess.check_output(['git', 'rev-list',
302 '--merges', *options,
303 b'..'.join([some_hash, target])])
304 return text.rstrip(b'\n').split(b'\n')
305
306 @classmethod
307 def merge_hash(cls, some_hash):
308 """Return the git commit id at which the given commit was merged.
309
310 Return None if the given commit was never merged.
311 """
312 target = b'HEAD'
313 # List the merges from some_hash to the target in two ways.
314 # The ancestry list is the ones that are both descendants of
315 # some_hash and ancestors of the target.
316 ancestry = frozenset(cls.list_merges(some_hash, target,
317 '--ancestry-path'))
318 # The first_parents list only contains merges that are directly
319 # on the target branch. We want it in reverse order (oldest first).
320 first_parents = cls.list_merges(some_hash, target,
321 '--first-parent', '--reverse')
322 # Look for the oldest merge commit that's both on the direct path
323 # and directly on the target branch. That's the place where some_hash
324 # was merged on the target branch. See
325 # https://stackoverflow.com/questions/8475448/find-merge-commit-which-include-a-specific-commit
326 for commit in first_parents:
327 if commit in ancestry:
328 return commit
329 return None
330
331 @staticmethod
332 def commit_timestamp(commit_id):
Gilles Peskineac0f0862020-03-27 10:56:45 +0100333 """Return the timestamp of the given commit."""
334 text = subprocess.check_output(['git', 'show', '-s',
335 '--format=%ct',
336 commit_id])
337 return datetime.datetime.utcfromtimestamp(int(text))
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100338
339 @staticmethod
340 def file_timestamp(filename):
341 """Return the modification timestamp of the given file."""
342 mtime = os.stat(filename).st_mtime
343 return datetime.datetime.fromtimestamp(mtime)
344
345 def __init__(self, filename):
Gilles Peskine28af9582020-03-26 22:39:18 +0100346 """Determine position of the file in the changelog entry order.
347
348 This constructor returns an object that can be used with comparison
349 operators, with `sort` and `sorted`, etc. Older entries are sorted
350 before newer entries.
351 """
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100352 self.filename = filename
353 creation_hash = self.creation_hash(filename)
354 if not creation_hash:
355 self.category = self.LOCAL
356 self.datetime = self.file_timestamp(filename)
357 return
358 merge_hash = self.merge_hash(creation_hash)
359 if not merge_hash:
360 self.category = self.COMMITTED
361 self.datetime = self.commit_timestamp(creation_hash)
362 return
363 self.category = self.MERGED
364 self.datetime = self.commit_timestamp(merge_hash)
365
366 def sort_key(self):
Gilles Peskine28af9582020-03-26 22:39:18 +0100367 """"Return a concrete sort key for this entry file sort key object.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100368
Gilles Peskine28af9582020-03-26 22:39:18 +0100369 ``ts1 < ts2`` is implemented as ``ts1.sort_key() < ts2.sort_key()``.
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100370 """
371 return (self.category, self.datetime, self.filename)
372
373 def __eq__(self, other):
374 return self.sort_key() == other.sort_key()
375
376 def __lt__(self, other):
377 return self.sort_key() < other.sort_key()
378
379
Gilles Peskine2b242492020-01-22 15:41:50 +0100380def check_output(generated_output_file, main_input_file, merged_files):
381 """Make sanity checks on the generated output.
382
383 The intent of these sanity checks is to have reasonable confidence
384 that no content has been lost.
385
386 The sanity check is that every line that is present in an input file
387 is also present in an output file. This is not perfect but good enough
388 for now.
389 """
390 generated_output = set(open(generated_output_file, 'rb'))
391 for line in open(main_input_file, 'rb'):
392 if line not in generated_output:
393 raise LostContent('original file', line)
394 for merged_file in merged_files:
395 for line in open(merged_file, 'rb'):
396 if line not in generated_output:
397 raise LostContent(merged_file, line)
398
399def finish_output(changelog, output_file, input_file, merged_files):
Gilles Peskine40b3f412019-10-13 21:44:25 +0200400 """Write the changelog to the output file.
401
Gilles Peskine2b242492020-01-22 15:41:50 +0100402 The input file and the list of merged files are used only for sanity
403 checks on the output.
Gilles Peskine40b3f412019-10-13 21:44:25 +0200404 """
405 if os.path.exists(output_file) and not os.path.isfile(output_file):
406 # The output is a non-regular file (e.g. pipe). Write to it directly.
407 output_temp = output_file
408 else:
409 # The output is a regular file. Write to a temporary file,
410 # then move it into place atomically.
411 output_temp = output_file + '.tmp'
412 changelog.write(output_temp)
Gilles Peskine2b242492020-01-22 15:41:50 +0100413 check_output(output_temp, input_file, merged_files)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200414 if output_temp != output_file:
415 os.rename(output_temp, output_file)
416
Gilles Peskine5e39c9e2020-01-22 14:55:37 +0100417def remove_merged_entries(files_to_remove):
418 for filename in files_to_remove:
419 os.remove(filename)
420
Gilles Peskine27a1fac2020-03-25 16:34:18 +0100421def list_files_to_merge(options):
422 """List the entry files to merge, oldest first.
423
Gilles Peskine28af9582020-03-26 22:39:18 +0100424 "Oldest" is defined by `EntryFileSortKey`.
Gilles Peskine27a1fac2020-03-25 16:34:18 +0100425 """
Gilles Peskine6e97c432020-03-27 19:05:18 +0100426 files_to_merge = glob.glob(os.path.join(options.dir, '*.txt'))
Gilles Peskine7fa3eb72020-03-26 22:41:32 +0100427 files_to_merge.sort(key=EntryFileSortKey)
Gilles Peskine27a1fac2020-03-25 16:34:18 +0100428 return files_to_merge
429
Gilles Peskine40b3f412019-10-13 21:44:25 +0200430def merge_entries(options):
431 """Merge changelog entries into the changelog file.
432
433 Read the changelog file from options.input.
434 Read entries to merge from the directory options.dir.
435 Write the new changelog to options.output.
436 Remove the merged entries if options.keep_entries is false.
437 """
438 with open(options.input, 'rb') as input_file:
Gilles Peskine6e97c432020-03-27 19:05:18 +0100439 changelog = ChangeLog(input_file, TextChangelogFormat)
Gilles Peskine27a1fac2020-03-25 16:34:18 +0100440 files_to_merge = list_files_to_merge(options)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200441 if not files_to_merge:
442 sys.stderr.write('There are no pending changelog entries.\n')
443 return
444 for filename in files_to_merge:
445 with open(filename, 'rb') as input_file:
446 changelog.add_file(input_file)
Gilles Peskine2b242492020-01-22 15:41:50 +0100447 finish_output(changelog, options.output, options.input, files_to_merge)
Gilles Peskine5e39c9e2020-01-22 14:55:37 +0100448 if not options.keep_entries:
449 remove_merged_entries(files_to_merge)
Gilles Peskine40b3f412019-10-13 21:44:25 +0200450
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100451def show_file_timestamps(options):
452 """List the files to merge and their timestamp.
453
454 This is only intended for debugging purposes.
455 """
456 files = list_files_to_merge(options)
457 for filename in files:
Gilles Peskine28af9582020-03-26 22:39:18 +0100458 ts = EntryFileSortKey(filename)
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100459 print(ts.category, ts.datetime, filename)
460
Gilles Peskine40b3f412019-10-13 21:44:25 +0200461def set_defaults(options):
462 """Add default values for missing options."""
463 output_file = getattr(options, 'output', None)
464 if output_file is None:
465 options.output = options.input
466 if getattr(options, 'keep_entries', None) is None:
467 options.keep_entries = (output_file is not None)
468
469def main():
470 """Command line entry point."""
471 parser = argparse.ArgumentParser(description=__doc__)
472 parser.add_argument('--dir', '-d', metavar='DIR',
473 default='ChangeLog.d',
Gilles Peskine6e910092020-01-22 15:58:18 +0100474 help='Directory to read entries from'
475 ' (default: ChangeLog.d)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200476 parser.add_argument('--input', '-i', metavar='FILE',
Gilles Peskine6e97c432020-03-27 19:05:18 +0100477 default='ChangeLog',
Gilles Peskine6e910092020-01-22 15:58:18 +0100478 help='Existing changelog file to read from and augment'
Gilles Peskine6e97c432020-03-27 19:05:18 +0100479 ' (default: ChangeLog)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200480 parser.add_argument('--keep-entries',
481 action='store_true', dest='keep_entries', default=None,
Gilles Peskine6e910092020-01-22 15:58:18 +0100482 help='Keep the files containing entries'
483 ' (default: remove them if --output/-o is not specified)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200484 parser.add_argument('--no-keep-entries',
485 action='store_false', dest='keep_entries',
Gilles Peskine6e910092020-01-22 15:58:18 +0100486 help='Remove the files containing entries after they are merged'
487 ' (default: remove them if --output/-o is not specified)')
Gilles Peskine40b3f412019-10-13 21:44:25 +0200488 parser.add_argument('--output', '-o', metavar='FILE',
Gilles Peskine6e910092020-01-22 15:58:18 +0100489 help='Output changelog file'
490 ' (default: overwrite the input)')
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100491 parser.add_argument('--list-files-only',
492 action='store_true',
Gilles Peskinec68c7c82020-03-27 19:01:35 +0100493 help=('Only list the files that would be processed '
Gilles Peskineac0f0862020-03-27 10:56:45 +0100494 '(with some debugging information)'))
Gilles Peskine40b3f412019-10-13 21:44:25 +0200495 options = parser.parse_args()
496 set_defaults(options)
Gilles Peskine8f46bbf2020-03-25 16:34:43 +0100497 if options.list_files_only:
498 show_file_timestamps(options)
499 return
Gilles Peskine40b3f412019-10-13 21:44:25 +0200500 merge_entries(options)
501
502if __name__ == '__main__':
503 main()