summaryrefslogtreecommitdiffstats
path: root/scripts/debdiff-apply
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/debdiff-apply')
-rwxr-xr-xscripts/debdiff-apply382
1 files changed, 382 insertions, 0 deletions
diff --git a/scripts/debdiff-apply b/scripts/debdiff-apply
new file mode 100755
index 0000000..aa4d8b8
--- /dev/null
+++ b/scripts/debdiff-apply
@@ -0,0 +1,382 @@
+#!/usr/bin/python3
+# Copyright (c) 2016-2017, Ximin Luo <infinity0@debian.org>
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 3
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# See file /usr/share/common-licenses/GPL-3 for more details.
+#
+
+# pylint: disable=invalid-name
+# pylint: enable=invalid-name
+
+"""
+Apply a debdiff to a Debian source package.
+
+It handles d/changelog hunks specially, to avoid conflicts.
+
+Depends on dpkg-dev, devscripts, python3-unidiff, quilt.
+"""
+
+import argparse
+import email.utils
+import hashlib
+import logging
+import os
+import shutil
+import subprocess
+import sys
+import tempfile
+import time
+
+try:
+ import unidiff
+except ImportError:
+ print(
+ "Please install 'python3-unidiff' in order to use this utility.",
+ file=sys.stderr,
+ )
+ sys.exit(1)
+from debian.changelog import ChangeBlock, Changelog
+
+# this can be any valid value, it doesn't appear in the final output
+DCH_DUMMY_TAIL = (
+ "\n -- debdiff-apply dummy tool <infinity0@debian.org> "
+ "Thu, 01 Jan 1970 00:00:00 +0000\n\n"
+)
+CHBLOCK_DUMMY_PACKAGE = "debdiff-apply PLACEHOLDER"
+TRY_ENCODINGS = ["utf-8", "latin-1"]
+DISTRIBUTION_DEFAULT = "experimental"
+
+
+def workaround_dpkg_865430(dscfile, origdir, stdout):
+ filename = subprocess.check_output(["dcmd", "--tar", "echo", dscfile]).rstrip()
+ if not os.path.exists(
+ os.path.join(origdir.encode("utf-8"), os.path.basename(filename))
+ ):
+ subprocess.check_call(["dcmd", "--tar", "cp", dscfile, origdir], stdout=stdout)
+
+
+def is_dch(path):
+ dirname = os.path.dirname(path)
+ return (
+ os.path.basename(path) == "changelog"
+ and os.path.basename(dirname) == "debian"
+ and os.path.dirname(os.path.dirname(dirname)) == ""
+ )
+
+
+def hunk_lines_to_str(hunk_lines):
+ return "".join(map(lambda x: str(x)[1:], hunk_lines))
+
+
+def read_dch_patch(dch_patch):
+ if len(dch_patch) > 1:
+ raise ValueError(
+ "don't know how to deal with debian/changelog patch "
+ "that has more than one hunk"
+ )
+ hunk = dch_patch[0]
+ source_str = hunk_lines_to_str(hunk.source_lines()) + DCH_DUMMY_TAIL
+ target_str = hunk_lines_to_str(hunk.target_lines())
+ # here we assume the debdiff has enough context to see the previous version
+ # this should be true all the time in practice
+ source_version = str(Changelog(source_str, 1)[0].version)
+ target = Changelog(target_str, 1)[0]
+ return source_version, target
+
+
+def apply_dch_patch(source_file, current, old_version, target, dry_run):
+ target_version = str(target.version)
+
+ if not old_version or not target_version.startswith(old_version):
+ logging.warning(
+ "don't know how to rebase version-change (%s => %s) onto %s",
+ old_version,
+ target_version,
+ old_version,
+ )
+ newlog = subprocess.getoutput("EDITOR=cat dch -n 2>/dev/null").rstrip()
+ version = str(Changelog(newlog, 1)[0].version)
+ logging.warning(
+ "using version %s based on `dch -n`; feel free to make me smarter", version
+ )
+ else:
+ version_suffix = target_version[len(old_version) :]
+ version = str(current[0].version) + version_suffix
+ logging.info("using version %s based on suffix %s", version, version_suffix)
+
+ if dry_run:
+ return version
+
+ current._blocks.insert(0, target) # pylint: disable=protected-access
+ current.set_version(version)
+
+ shutil.copy(source_file, source_file + ".new")
+ try:
+ # disable unspecified-encoding, as in Mattia's opinion this should
+ # likely be rewritten to use pure binary instead of encode/decode.
+ # pylint: disable=unspecified-encoding
+ with open(source_file + ".new", "w") as fp:
+ current.write_to_open_file(fp)
+ os.rename(source_file + ".new", source_file)
+ except Exception:
+ logging.warning("failed to patch %s", source_file)
+ logging.warning("half-applied changes in %s", source_file + ".new")
+ logging.warning("current working directory is %s", os.getcwd())
+ raise
+ return version
+
+
+def call_patch(patch_str, *args, check=True, **kwargs):
+ return subprocess.run(
+ ["patch", "-p1"] + list(args),
+ input=patch_str,
+ universal_newlines=True,
+ check=check,
+ **kwargs,
+ )
+
+
+def check_patch(patch_str, *args, **kwargs):
+ patch = call_patch(
+ patch_str,
+ "--dry-run",
+ "-f",
+ "--silent",
+ *args,
+ check=False,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ **kwargs,
+ )
+ return patch.returncode == 0
+
+
+def debdiff_apply(patch, patch_name, args):
+ # don't change anything if...
+ dry_run = args.target_version or args.source_version
+
+ changelog = list(filter(lambda x: is_dch(x.path), patch))
+ if not changelog:
+ logging.info("no debian/changelog in patch: %s", args.patch_file)
+ old_version = None
+ target = ChangeBlock(
+ package=CHBLOCK_DUMMY_PACKAGE,
+ author=f"{os.getenv('DEBFULLNAME')} <{os.getenv('DEBEMAIL')}>",
+ date=email.utils.formatdate(time.time(), localtime=True),
+ version=None,
+ distributions=args.distribution,
+ urgency="low",
+ changes=["", f" * Rebase patch {patch_name}.", ""],
+ )
+ target.add_trailing_line("")
+ elif len(changelog) > 1:
+ raise ValueError("more than one debian/changelog patch???")
+ else:
+ patch.remove(changelog[0])
+ old_version, target = read_dch_patch(changelog[0])
+
+ if args.source_version:
+ if old_version:
+ print(old_version)
+ return False
+
+ # read this here so --source-version can work even without a d/changelog
+ with open(args.changelog, encoding="utf8") as fp:
+ current = Changelog(fp.read())
+ if target.package == CHBLOCK_DUMMY_PACKAGE:
+ target.package = current[0].package
+
+ if not dry_run:
+ patch_str = str(patch)
+ if check_patch(patch_str, "-N"):
+ call_patch(patch_str)
+ logging.info("patch %s applies!", patch_name)
+ elif check_patch(patch_str, "-R"):
+ logging.warning("patch %s already applied", patch_name)
+ return False
+ else:
+ call_patch(patch_str, "--dry-run", "-f")
+ raise ValueError(f"patch {patch_name} doesn't apply!")
+
+ # only apply d/changelog patch if the rest of the patch applied
+ new_version = apply_dch_patch(args.changelog, current, old_version, target, dry_run)
+ if args.target_version:
+ print(new_version)
+ return False
+
+ if args.repl:
+ import code # pylint: disable=import-outside-toplevel
+
+ code.interact(local=locals())
+
+ return True
+
+
+def parse_args(args):
+ parser = argparse.ArgumentParser(
+ description="Apply a debdiff to a Debian source package"
+ )
+ parser.add_argument(
+ "-v", "--verbose", action="store_true", help="Output more information"
+ )
+ parser.add_argument(
+ "-c",
+ "--changelog",
+ default="debian/changelog",
+ help="Path to debian/changelog; default: %(default)s",
+ )
+ parser.add_argument(
+ "-D",
+ "--distribution",
+ default="experimental",
+ help="Distribution to use, if the patch doesn't already "
+ "contain a changelog; default: %(default)s",
+ )
+ parser.add_argument(
+ "--repl", action="store_true", help="Run the python REPL after processing."
+ )
+ parser.add_argument(
+ "--source-version",
+ action="store_true",
+ help="Don't apply the patch; instead print out the version of the "
+ "package that it is supposed to be applied to, or nothing if "
+ "the patch does not specify a source version.",
+ )
+ parser.add_argument(
+ "--target-version",
+ action="store_true",
+ help="Don't apply the patch; instead print out the new version of the "
+ "package debdiff-apply(1) would generate, when the patch is applied to the "
+ "the given target package, as specified by the other arguments.",
+ )
+ parser.add_argument(
+ "orig_dsc_or_dir",
+ nargs="?",
+ default=".",
+ help="Target to apply the patch to. This can either be an unpacked "
+ "source tree, or a .dsc file. In the former case, the directory is "
+ "modified in-place; in the latter case, a second .dsc is created. "
+ "Default: %(default)s",
+ )
+ parser.add_argument(
+ "patch_file",
+ nargs="?",
+ default="/dev/stdin",
+ help="Patch file to apply, in the format output by debdiff(1)."
+ " Default: %(default)s",
+ )
+ group1 = parser.add_argument_group("Options for .dsc patch targets")
+ group1.add_argument(
+ "--no-clean",
+ action="store_true",
+ help="Don't clean temporary directories after a failure, so you can "
+ "examine what failed.",
+ )
+ group1.add_argument(
+ "--quilt-refresh",
+ action="store_true",
+ help="If the building of the new source package fails, try to refresh "
+ "patches using quilt(1) then try building it again.",
+ )
+ group1.add_argument(
+ "-d",
+ "--directory",
+ default=None,
+ help="Extract the .dsc into this directory, which won't be cleaned up "
+ "after debdiff-apply(1) exits. If not given, then it will be extracted to a "
+ "temporary directory.",
+ )
+ return parser.parse_args(args)
+
+
+def main(args):
+ # Split this function!
+ # pylint: disable=too-many-branches,too-many-locals,too-many-statements
+ args = parse_args(args)
+ if args.verbose:
+ logging.getLogger().setLevel(logging.DEBUG)
+
+ with open(args.patch_file, "rb") as fp:
+ data = fp.read()
+ for enc in TRY_ENCODINGS:
+ try:
+ patch = unidiff.PatchSet(data.splitlines(keepends=True), encoding=enc)
+ break
+ except Exception: # pylint: disable=broad-except
+ if enc == TRY_ENCODINGS[-1]:
+ raise
+ continue
+
+ hex_digest = hashlib.sha256(data).hexdigest()[
+ : 20 if args.patch_file == "/dev/stdin" else 8
+ ]
+ patch_name = f"{os.path.basename(args.patch_file)}:{hex_digest}"
+ quiet = args.source_version or args.target_version
+ dry_run = args.source_version or args.target_version
+ # user can redirect stderr themselves
+ stdout = subprocess.DEVNULL if quiet else None
+
+ # change directory before applying patches
+ if os.path.isdir(args.orig_dsc_or_dir):
+ os.chdir(args.orig_dsc_or_dir)
+ debdiff_apply(patch, patch_name, args)
+ elif os.path.isfile(args.orig_dsc_or_dir):
+ dscfile = args.orig_dsc_or_dir
+ parts = os.path.splitext(os.path.basename(dscfile))
+ if parts[1] != ".dsc":
+ raise ValueError(f"unrecognised patch target: {dscfile}")
+ extractdir = args.directory if args.directory else tempfile.mkdtemp()
+ if not os.path.isdir(extractdir):
+ os.makedirs(extractdir)
+ try:
+ # dpkg-source doesn't like existing dirs
+ builddir = os.path.join(extractdir, parts[0])
+ subprocess.check_call(
+ ["dpkg-source", "-x", "--skip-patches", dscfile, builddir],
+ stdout=stdout,
+ )
+ origdir = os.getcwd()
+ workaround_dpkg_865430(dscfile, origdir, stdout)
+ os.chdir(builddir)
+ did_patch = debdiff_apply(patch, patch_name, args)
+ if dry_run or not did_patch:
+ return
+ os.chdir(origdir)
+ try:
+ subprocess.check_call(["dpkg-source", "-b", builddir])
+ except subprocess.CalledProcessError:
+ if args.quilt_refresh:
+ subprocess.check_call(
+ [
+ "sh",
+ "-c",
+ """
+set -ex
+export QUILT_PATCHES=debian/patches
+while quilt push; do quilt refresh; done
+""",
+ ],
+ cwd=builddir,
+ )
+ subprocess.check_call(["dpkg-source", "-b", builddir])
+ else:
+ raise
+ finally:
+ cleandir = builddir if args.directory else extractdir
+ if args.no_clean:
+ logging.warning("you should clean up temp files in %s", cleandir)
+ else:
+ shutil.rmtree(cleandir)
+
+
+if __name__ == "__main__":
+ sys.exit(main(sys.argv[1:]))