1 # vim: set fileencoding=utf-8 :
3 # (C) 2007,2008,2009,2010,2013,2015,2017 Guido Günther <agx@sigxcpu.org>
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU General Public License for more details.
14 # You should have received a copy of the GNU General Public License
15 # along with this program; if not, please see
16 # <http://www.gnu.org/licenses/>
18 """Generate Debian changelog entries from Git commit messages"""
24 import gbp.command_wrappers as gbpc
27 from gbp.config import GbpOptionParserDebian, GbpOptionGroup
28 from gbp.errors import GbpError
29 from gbp.deb import compare_versions
30 from gbp.deb.source import DebianSource, DebianSourceError
31 from gbp.deb.git import GitRepositoryError, DebianGitRepository
32 from gbp.deb.changelog import ChangeLog, NoChangeLogError
33 from gbp.scripts.common import ExitCodes, maybe_debug_raise
34 from gbp.scripts.common.hook import Hook
36 user_customizations = {}
37 snapshot_re = re.compile(r"\s*\*\* SNAPSHOT build @(?P<commit>[a-z0-9]+)\s+\*\*")
40 def guess_version_from_upstream(repo, upstream_tag_format, upstream_branch, cp=None):
42 Guess the version based on the latest version on the upstream branch.
43 If the version in dch is already higher this function returns None.
45 epoch = cp.epoch if cp else None
46 cmp_version = cp.version if cp else '0~'
48 version = repo.debian_version_from_upstream(upstream_tag_format,
52 gbp.log.debug("Found upstream version %s." % version)
53 if compare_versions(version, cmp_version) > 0:
54 return "%s-1" % version
55 except GitRepositoryError as e:
56 gbp.log.debug("No upstream tag found: %s" % e)
60 def get_author_email(repo, use_git_config):
61 """Get author and email from git configuration"""
66 author = repo.get_config('user.name')
71 email = repo.get_config('user.email')
77 def fixup_section(repo, use_git_author, options, dch_options):
79 Fixup the changelog header and trailer's committer and email address
81 It might otherwise point to the last git committer instead of the person
82 creating the changelog
84 This also applies --distribution and --urgency options passed to gbp dch
86 author, email = get_author_email(repo, use_git_author)
87 used_options = ['distribution', 'urgency']
89 mainttrailer_opts = ['--nomainttrailer', '--mainttrailer', '-t']
91 # This must not be done for snapshots or snapshots changelog entries
92 # will not be concatenated
93 if not options.snapshot:
94 for opt in used_options:
95 val = getattr(options, opt)
97 gbp.log.debug("Set header option '%s' to '%s'" % (opt, val))
98 opts.append("--%s=%s" % (opt, val))
100 gbp.log.debug("Snapshot enabled: do not fixup options in header")
102 for opt in mainttrailer_opts:
103 if opt in dch_options:
106 opts.append(mainttrailer_opts[0])
107 ChangeLog.spawn_dch(msg='', author=author, email=email, dch_options=dch_options + opts)
110 def snapshot_version(version):
112 Get the current release and snapshot version.
114 Format is <debian-version>~<release>.gbp<short-commit-id>
116 >>> snapshot_version('1.0-1')
118 >>> snapshot_version('1.0-1~1.test0')
120 >>> snapshot_version('1.0-1~2.gbp1234')
124 (release, suffix) = version.rsplit('~', 1)
125 (snapshot, commit) = suffix.split('.', 1)
126 if not commit.startswith('gbp'):
129 snapshot = int(snapshot)
130 except ValueError: # not a snapshot release
133 return release, snapshot
136 def mangle_changelog(changelog, cp, snapshot=''):
138 Mangle changelog to either add or remove snapshot markers
140 @param snapshot: SHA1 if snapshot header should be added/maintained,
141 empty if it should be removed
142 @type snapshot: C{str}
145 tmpfile = '%s.%s' % (changelog, snapshot)
146 cw = open(tmpfile, 'w', encoding='utf-8')
147 cr = open(changelog, 'r', encoding='utf-8')
149 print("%(Source)s (%(MangledVersion)s) "
150 "%(Distribution)s; urgency=%(urgency)s\n" % cp, file=cw)
152 cr.readline() # skip version and empty line
155 if snapshot_re.match(line):
156 cr.readline() # consume the empty line after the snapshot header
160 print(" ** SNAPSHOT build @%s **\n" % snapshot, file=cw)
163 print(line.rstrip(), file=cw)
164 shutil.copyfileobj(cr, cw)
168 os.rename(tmpfile, changelog)
170 raise GbpError("Error mangling changelog %s" % e)
173 def do_release(changelog, repo, cp, use_git_author, dch_options):
174 """Remove the snapshot header and set the distribution"""
175 author, email = get_author_email(repo, use_git_author)
176 (release, snapshot) = snapshot_version(cp['Version'])
178 cp['MangledVersion'] = release
179 mangle_changelog(changelog, cp)
180 cp.spawn_dch(release=True, author=author, email=email, dch_options=dch_options)
183 def do_snapshot(changelog, repo, next_snapshot):
185 Add new snapshot banner to most recent changelog section.
186 The next snapshot number is calculated by eval()'ing next_snapshot.
190 cp = ChangeLog(filename=changelog)
191 (release, snapshot) = snapshot_version(cp['Version'])
192 snapshot = int(eval(next_snapshot))
194 suffix = "%d.gbp%s" % (snapshot, "".join(commit[0:6]))
195 cp['MangledVersion'] = "%s~%s" % (release, suffix)
197 mangle_changelog(changelog, cp, commit)
198 return snapshot, commit, cp['MangledVersion']
201 def parse_commit(repo, commitid, opts, last_commit=False):
202 """Parse a commit and return message, author, and author email"""
203 commit_info = repo.get_commit_info(commitid)
204 author = commit_info['author'].name
205 email = commit_info['author'].email
206 format_entry = user_customizations.get('format_changelog_entry')
208 format_entry = dch.format_changelog_entry
209 entry = format_entry(commit_info, opts, last_commit=last_commit)
210 return entry, (author, email)
213 def guess_documented_commit(cp, repo, tagformat):
215 Guess the last commit documented in the changelog from the snapshot banner,
216 the last tagged version or the last point the changelog was touched.
218 @param cp: the changelog
219 @param repo: the git repository
220 @param tagformat: the format for Debian tags
221 @returns: the commit that was last documented in the changelog
223 @raises GbpError: In case we fail to find a commit to start at
225 # Check for snapshot banner
226 sr = re.search(snapshot_re, cp['Changes'])
228 return sr.group('commit')
230 # Check if the latest version in the changelog is already tagged. If
231 # so this is the last documented commit.
232 commit = repo.find_version(tagformat, cp.version)
234 gbp.log.info("Found tag for topmost changelog version '%s'" % commit)
237 # Check when the changelog was last touched
238 last = repo.get_commits(paths="debian/changelog", num=1)
240 gbp.log.info("Changelog last touched at '%s'" % last[0])
243 # Changelog not touched yet
247 def has_snapshot_banner(cp):
248 """Whether the changelog has a snapshot banner"""
249 sr = re.search(snapshot_re, cp['Changes'])
250 return True if sr else False
253 def get_customizations(customization_file):
254 if customization_file:
256 with open(customization_file) as f:
257 exec(f.read(), user_customizations, user_customizations)
258 except Exception as err:
259 raise GbpError("Failed to load customization file: %s" % err)
262 def process_options(options, parser):
263 if options.snapshot and options.release:
264 parser.error("'--snapshot' and '--release' are incompatible options")
266 if options.since and options.auto:
267 parser.error("'--since' and '--auto' are incompatible options")
269 if not options.since and not options.auto:
273 if options.multimaint_merge:
274 dch_options.append("--multimaint-merge")
276 dch_options.append("--nomultimaint-merge")
278 if options.multimaint:
279 dch_options.append("--multimaint")
281 dch_options.append("--nomultimaint")
283 if options.force_distribution:
284 dch_options.append("--force-distribution")
286 return dch_options + options.dch_opts
289 def process_editor_option(options):
290 """Determine text editor and check if we need it"""
294 states.append("snapshot")
295 elif options.release:
296 states.append("release")
298 if options.spawn_editor == 'never' or options.spawn_editor not in states:
301 return "sensible-editor"
304 def changelog_commit_msg(options, version):
305 return options.commit_msg % dict(version=version)
308 def create_changelog(repo, source, options):
310 name = source.control.name
311 except DebianSourceError:
312 raise GbpError("Did not find debian/changelog or debian/source. Is this a Debian package?")
313 version = guess_version_from_upstream(repo, options.upstream_tag,
314 options.upstream_branch, None)
315 return ChangeLog.create(name, version)
318 def maybe_create_changelog(repo, source, options):
320 Get the changelog or create a new one if it does not exist yet
323 return source.changelog
324 except DebianSourceError:
325 return create_changelog(repo, source, options)
328 def build_parser(name):
330 parser = GbpOptionParserDebian(command=os.path.basename(name),
331 usage='%prog [options] paths')
332 except GbpError as err:
336 range_group = GbpOptionGroup(parser, "commit range options",
337 "which commits to add to the changelog")
338 version_group = GbpOptionGroup(parser, "release & version number options",
339 "what version number and release to use")
340 commit_group = GbpOptionGroup(parser, "commit message formatting",
341 "howto format the changelog entries")
342 naming_group = GbpOptionGroup(parser, "branch and tag naming",
343 "branch names and tag formats")
344 custom_group = GbpOptionGroup(parser, "customization",
345 "options for customization")
346 parser.add_option_group(range_group)
347 parser.add_option_group(version_group)
348 parser.add_option_group(commit_group)
349 parser.add_option_group(naming_group)
350 parser.add_option_group(custom_group)
352 parser.add_boolean_config_file_option(option_name="ignore-branch", dest="ignore_branch")
353 naming_group.add_config_file_option(option_name="upstream-branch", dest="upstream_branch")
354 naming_group.add_config_file_option(option_name="debian-branch", dest="debian_branch")
355 naming_group.add_config_file_option(option_name="upstream-tag", dest="upstream_tag")
356 naming_group.add_config_file_option(option_name="debian-tag", dest="debian_tag")
357 naming_group.add_config_file_option(option_name="snapshot-number", dest="snapshot_number",
358 help="expression to determine the next snapshot number, "
359 "default is '%(snapshot-number)s'")
360 parser.add_config_file_option(option_name="git-log", dest="git_log",
361 help="options to pass to git-log, "
362 "default is '%(git-log)s'")
363 parser.add_option("-v", "--verbose", action="store_true", dest="verbose", default=False,
364 help="verbose command execution")
365 parser.add_config_file_option(option_name="color", dest="color", type='tristate')
366 parser.add_config_file_option(option_name="color-scheme",
368 range_group.add_option("-s", "--since", dest="since", help="commit to start from (e.g. HEAD^^^, debian/0.4.3)")
369 range_group.add_option("-a", "--auto", action="store_true", dest="auto", default=False,
370 help="autocomplete changelog from last snapshot or tag")
371 version_group.add_option("-R", "--release", action="store_true", dest="release", default=False,
372 help="mark as release")
373 version_group.add_option("-S", "--snapshot", action="store_true", dest="snapshot", default=False,
374 help="mark as snapshot build")
375 version_group.add_option("-D", "--distribution", dest="distribution", help="Set distribution")
376 version_group.add_option("--force-distribution", action="store_true", dest="force_distribution", default=False,
377 help="Force the provided distribution to be used, "
378 "even if it doesn't match the list of known distributions")
379 version_group.add_option("-N", "--new-version", dest="new_version",
380 help="use this as base for the new version number")
381 version_group.add_config_file_option("urgency", dest="urgency")
382 version_group.add_option("--bpo", dest="bpo", action="store_true", default=False,
383 help="Increment the Debian release number for an upload to backports, "
384 "and add a backport upload changelog comment.")
385 version_group.add_option("--nmu", dest="nmu", action="store_true", default=False,
386 help="Increment the Debian release number for a non-maintainer upload")
387 version_group.add_option("--qa", dest="qa", action="store_true", default=False,
388 help="Increment the Debian release number for a Debian QA Team upload, "
389 "and add a QA upload changelog comment.")
390 version_group.add_option("--team", dest="team", action="store_true", default=False,
391 help="Increment the Debian release number for a Debian Team upload, "
392 "and add a Team upload changelog comment.")
393 version_group.add_option("--security", dest="security", action="store_true", default=False,
394 help="Increment the Debian release number for a security upload and "
395 "add a security upload changelog comment.")
396 version_group.add_boolean_config_file_option(option_name="git-author", dest="use_git_author")
397 commit_group.add_boolean_config_file_option(option_name="meta", dest="meta")
398 commit_group.add_config_file_option(option_name="meta-closes", dest="meta_closes")
399 commit_group.add_config_file_option(option_name="meta-closes-bugnum", dest="meta_closes_bugnum")
400 commit_group.add_boolean_config_file_option(option_name="full", dest="full")
401 commit_group.add_config_file_option(option_name="id-length", dest="idlen",
402 help="include N digits of the commit id in the changelog entry, "
403 "default is '%(id-length)s'",
404 type="int", metavar="N")
405 commit_group.add_config_file_option(option_name="ignore-regex", dest="ignore_regex",
406 help="Ignore commit lines matching regex, "
407 "default is '%(ignore-regex)s'")
408 commit_group.add_boolean_config_file_option(option_name="multimaint", dest="multimaint")
409 commit_group.add_boolean_config_file_option(option_name="multimaint-merge", dest="multimaint_merge")
410 commit_group.add_config_file_option(option_name="spawn-editor", dest="spawn_editor")
411 parser.add_config_file_option(option_name="commit-msg",
413 parser.add_option("-c", "--commit", action="store_true", dest="commit", default=False,
414 help="commit changelog file after generating")
415 parser.add_config_file_option(option_name="dch-opt",
416 dest="dch_opts", action="append",
417 help="option to pass to dch verbatim, "
418 "can be given multiple times",
421 help_msg = ('Load Python code from CUSTOMIZATION_FILE. At the moment,'
422 ' the only useful thing the code can do is define a custom'
423 ' format_changelog_entry() function.')
424 custom_group.add_config_file_option(option_name="customizations",
425 dest="customization_file",
427 custom_group.add_config_file_option(option_name="postedit", dest="postedit",
428 help="Hook to run after changes to the changelog file"
429 "have been finalized default is '%(postedit)s'")
433 def parse_args(argv):
434 parser = build_parser(argv[0])
438 (options, args) = parser.parse_args(argv[1:])
439 gbp.log.setup(options.color, options.verbose, options.color_scheme)
440 dch_options = process_options(options, parser)
441 editor_cmd = process_editor_option(options)
442 return options, args, dch_options, editor_cmd
447 changelog = 'debian/changelog'
449 found_snapshot_banner = False
453 options, args, dch_options, editor_cmd = parse_args(argv)
456 return ExitCodes.parse_error
459 old_cwd = os.path.abspath(os.path.curdir)
461 repo = DebianGitRepository('.', toplevel=False)
463 except GitRepositoryError:
464 raise GbpError("%s is not a git repository" % (os.path.abspath('.')))
466 get_customizations(options.customization_file)
468 branch = repo.get_branch()
469 except GitRepositoryError:
470 # Not being on any branch is o.k. with --ignore-branch
471 if not options.ignore_branch:
474 if options.debian_branch != branch and not options.ignore_branch:
475 gbp.log.err("You are not on branch '%s' but on '%s'" % (options.debian_branch, branch))
476 raise GbpError("Use --ignore-branch to ignore or --debian-branch to set the branch name.")
478 source = DebianSource('.')
479 cp = maybe_create_changelog(repo, source, options)
482 since = options.since
484 since = guess_documented_commit(cp, repo, options.debian_tag)
486 msg = "Continuing from commit '%s'" % since
488 msg = "Starting from first commit"
490 found_snapshot_banner = has_snapshot_banner(cp)
493 gbp.log.info("Only looking for changes on '%s'" % " ".join(args))
494 commits = repo.get_commits(since=since, until=until, paths=args,
495 options=options.git_log.split(" "))
499 # add a new changelog section if:
500 if (options.new_version or options.bpo or options.nmu or options.qa or
501 options.team or options.security):
503 version_change['increment'] = '--bpo'
505 version_change['increment'] = '--nmu'
507 version_change['increment'] = '--qa'
509 version_change['increment'] = '--team'
510 elif options.security:
511 version_change['increment'] = '--security'
513 version_change['version'] = options.new_version
514 # the user wants to force a new version
516 elif cp['Distribution'] != "UNRELEASED" and not found_snapshot_banner:
518 # the last version was a release and we have pending commits
521 # the user want to switch to snapshot mode
524 if add_section and not version_change and not source.is_native():
525 # Get version from upstream if none provided
526 v = guess_version_from_upstream(repo, options.upstream_tag,
527 options.upstream_branch, cp)
529 version_change['version'] = v
534 parsed = parse_commit(repo, c, options,
535 last_commit=(i == len(commits)))
536 commit_msg, (commit_author, commit_email) = parsed
538 # Some commits can be ignored
542 # Add a section containing just this message (we can't
543 # add an empty section with dch)
544 cp.add_section(distribution="UNRELEASED", msg=commit_msg,
545 version=version_change,
546 author=commit_author,
548 dch_options=dch_options)
549 # Adding a section only needs to happen once.
552 cp.add_entry(commit_msg, commit_author, commit_email, dch_options)
554 # Show a message if there were no commits (not even ignored
557 gbp.log.info("No changes detected from %s to %s." % (since, until))
560 # If we end up here, then there were no commits to include,
561 # so we put a dummy message in the new section.
562 cp.add_section(distribution="UNRELEASED", msg=["UNRELEASED"],
563 version=version_change,
564 dch_options=dch_options)
566 fixup_section(repo, use_git_author=options.use_git_author, options=options,
567 dch_options=dch_options)
570 do_release(changelog, repo, cp, use_git_author=options.use_git_author,
571 dch_options=dch_options)
572 elif options.snapshot:
573 (snap, commit, version) = do_snapshot(changelog, repo, options.snapshot_number)
574 gbp.log.info("Changelog %s (snapshot #%d) prepared up to %s" % (version, snap, commit[:7]))
577 gbpc.Command(editor_cmd, ["debian/changelog"])()
580 cp = ChangeLog(filename=changelog)
581 Hook('Postimport', options.postedit,
582 extra_env={'GBP_DEBIAN_VERSION': cp.version})()
585 # Get the version from the changelog file (since dch might
586 # have incremented it, there's no way we can already know
588 version = ChangeLog(filename=changelog).version
589 # Commit the changes to the changelog file
590 msg = changelog_commit_msg(options, version)
591 repo.commit_files([changelog], msg)
592 gbp.log.info("Changelog committed for version %s" % version)
593 except KeyboardInterrupt:
595 gbp.log.err("Interrupted. Aborting.")
596 except (gbpc.CommandExecFailed,
600 NoChangeLogError) as err:
610 if __name__ == "__main__":
611 sys.exit(main(sys.argv))
613 # vim:et:ts=4:sw=4:et:sts=4:ai:set list listchars=tab\:»·,trail\:·: