patches.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. #!/usr/bin/env python3
  2. # -*- coding: UTF-8 -*-
  3. # Copyright (c) 2020 The ungoogled-chromium Authors. All rights reserved.
  4. # Use of this source code is governed by a BSD-style license that can be
  5. # found in the LICENSE file.
  6. """Applies unified diff patches"""
  7. import argparse
  8. import os
  9. import shutil
  10. import subprocess
  11. from pathlib import Path
  12. from _common import get_logger, parse_series, add_common_params
  13. def _find_patch_from_env():
  14. patch_bin_path = None
  15. patch_bin_env = os.environ.get('PATCH_BIN')
  16. if patch_bin_env:
  17. patch_bin_path = Path(patch_bin_env)
  18. if patch_bin_path.exists():
  19. get_logger().debug('Found PATCH_BIN with path "%s"', patch_bin_path)
  20. else:
  21. patch_which = shutil.which(patch_bin_env)
  22. if patch_which:
  23. get_logger().debug('Found PATCH_BIN for command with path "%s"', patch_which)
  24. patch_bin_path = Path(patch_which)
  25. else:
  26. get_logger().debug('PATCH_BIN env variable is not set')
  27. return patch_bin_path
  28. def _find_patch_from_which():
  29. patch_which = shutil.which('patch')
  30. if not patch_which:
  31. get_logger().debug('Did not find "patch" in PATH environment variable')
  32. return None
  33. return Path(patch_which)
  34. def find_and_check_patch(patch_bin_path=None):
  35. """
  36. Find and/or check the patch binary is working. It finds a path to patch in this order:
  37. 1. Use patch_bin_path if it is not None
  38. 2. See if "PATCH_BIN" environment variable is set
  39. 3. Do "which patch" to find GNU patch
  40. Then it does some sanity checks to see if the patch command is valid.
  41. Returns the path to the patch binary found.
  42. """
  43. if patch_bin_path is None:
  44. patch_bin_path = _find_patch_from_env()
  45. if patch_bin_path is None:
  46. patch_bin_path = _find_patch_from_which()
  47. if not patch_bin_path:
  48. raise ValueError('Could not find patch from PATCH_BIN env var or "which patch"')
  49. if not patch_bin_path.exists():
  50. raise ValueError('Could not find the patch binary: {}'.format(patch_bin_path))
  51. # Ensure patch actually runs
  52. cmd = [str(patch_bin_path), '--version']
  53. result = subprocess.run(
  54. cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
  55. if result.returncode:
  56. get_logger().error('"%s" returned non-zero exit code', ' '.join(cmd))
  57. get_logger().error('stdout:\n%s', result.stdout)
  58. get_logger().error('stderr:\n%s', result.stderr)
  59. raise RuntimeError('Got non-zero exit code running "{}"'.format(' '.join(cmd)))
  60. return patch_bin_path
  61. def dry_run_check(patch_path, tree_path, patch_bin_path=None):
  62. """
  63. Run patch --dry-run on a patch
  64. tree_path is the pathlib.Path of the source tree to patch
  65. patch_path is a pathlib.Path to check
  66. reverse is whether the patches should be reversed
  67. patch_bin_path is the pathlib.Path of the patch binary, or None to find it automatically
  68. See find_and_check_patch() for logic to find "patch"
  69. Returns the status code, stdout, and stderr of patch --dry-run
  70. """
  71. cmd = [
  72. str(find_and_check_patch(patch_bin_path)), '-p1', '--ignore-whitespace', '-i',
  73. str(patch_path), '-d',
  74. str(tree_path), '--no-backup-if-mismatch', '--dry-run'
  75. ]
  76. result = subprocess.run(
  77. cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
  78. return result.returncode, result.stdout, result.stderr
  79. def apply_patches(patch_path_iter, tree_path, reverse=False, patch_bin_path=None):
  80. """
  81. Applies or reverses a list of patches
  82. tree_path is the pathlib.Path of the source tree to patch
  83. patch_path_iter is a list or tuple of pathlib.Path to patch files to apply
  84. reverse is whether the patches should be reversed
  85. patch_bin_path is the pathlib.Path of the patch binary, or None to find it automatically
  86. See find_and_check_patch() for logic to find "patch"
  87. Raises ValueError if the patch binary could not be found.
  88. """
  89. patch_paths = list(patch_path_iter)
  90. patch_bin_path = find_and_check_patch(patch_bin_path=patch_bin_path)
  91. if reverse:
  92. patch_paths.reverse()
  93. logger = get_logger()
  94. for patch_path, patch_num in zip(patch_paths, range(1, len(patch_paths) + 1)):
  95. cmd = [
  96. str(patch_bin_path), '-p1', '--ignore-whitespace', '-i',
  97. str(patch_path), '-d',
  98. str(tree_path), '--no-backup-if-mismatch'
  99. ]
  100. if reverse:
  101. cmd.append('--reverse')
  102. log_word = 'Reversing'
  103. else:
  104. cmd.append('--forward')
  105. log_word = 'Applying'
  106. logger.info('* %s %s (%s/%s)', log_word, patch_path.name, patch_num, len(patch_paths))
  107. logger.debug(' '.join(cmd))
  108. subprocess.run(cmd, check=True)
  109. def generate_patches_from_series(patches_dir, resolve=False):
  110. """Generates pathlib.Path for patches from a directory in GNU Quilt format"""
  111. for patch_path in parse_series(patches_dir / 'series'):
  112. if resolve:
  113. yield (patches_dir / patch_path).resolve()
  114. else:
  115. yield patch_path
  116. def _copy_files(path_iter, source, destination):
  117. """Copy files from source to destination with relative paths from path_iter"""
  118. for path in path_iter:
  119. (destination / path).parent.mkdir(parents=True, exist_ok=True)
  120. shutil.copy2(str(source / path), str(destination / path))
  121. def merge_patches(source_iter, destination, prepend=False):
  122. """
  123. Merges GNU quilt-formatted patches directories from sources into destination
  124. destination must not already exist, unless prepend is True. If prepend is True, then
  125. the source patches will be prepended to the destination.
  126. """
  127. series = []
  128. known_paths = set()
  129. if destination.exists():
  130. if prepend:
  131. if not (destination / 'series').exists():
  132. raise FileNotFoundError(
  133. 'Could not find series file in existing destination: {}'.format(
  134. destination / 'series'))
  135. known_paths.update(generate_patches_from_series(destination))
  136. else:
  137. raise FileExistsError('destination already exists: {}'.format(destination))
  138. for source_dir in source_iter:
  139. patch_paths = tuple(generate_patches_from_series(source_dir))
  140. patch_intersection = known_paths.intersection(patch_paths)
  141. if patch_intersection:
  142. raise FileExistsError(
  143. 'Patches from {} have conflicting paths with other sources: {}'.format(
  144. source_dir, patch_intersection))
  145. series.extend(patch_paths)
  146. _copy_files(patch_paths, source_dir, destination)
  147. if prepend and (destination / 'series').exists():
  148. series.extend(generate_patches_from_series(destination))
  149. with (destination / 'series').open('w') as series_file:
  150. series_file.write('\n'.join(map(str, series)))
  151. def _apply_callback(args, parser_error):
  152. logger = get_logger()
  153. patch_bin_path = None
  154. if args.patch_bin is not None:
  155. patch_bin_path = Path(args.patch_bin)
  156. if not patch_bin_path.exists():
  157. patch_bin_path = shutil.which(args.patch_bin)
  158. if patch_bin_path:
  159. patch_bin_path = Path(patch_bin_path)
  160. else:
  161. parser_error(
  162. f'--patch-bin "{args.patch_bin}" is not a command or path to executable.')
  163. for patch_dir in args.patches:
  164. logger.info('Applying patches from %s', patch_dir)
  165. apply_patches(
  166. generate_patches_from_series(patch_dir, resolve=True),
  167. args.target,
  168. patch_bin_path=patch_bin_path)
  169. def _merge_callback(args, _):
  170. merge_patches(args.source, args.destination, args.prepend)
  171. def main():
  172. """CLI Entrypoint"""
  173. parser = argparse.ArgumentParser()
  174. add_common_params(parser)
  175. subparsers = parser.add_subparsers()
  176. apply_parser = subparsers.add_parser(
  177. 'apply', help='Applies patches (in GNU Quilt format) to the specified source tree')
  178. apply_parser.add_argument(
  179. '--patch-bin', help='The GNU patch command to use. Omit to find it automatically.')
  180. apply_parser.add_argument('target', type=Path, help='The directory tree to apply patches onto.')
  181. apply_parser.add_argument(
  182. 'patches',
  183. type=Path,
  184. nargs='+',
  185. help='The directories containing patches to apply. They must be in GNU quilt format')
  186. apply_parser.set_defaults(callback=_apply_callback)
  187. merge_parser = subparsers.add_parser(
  188. 'merge', help='Merges patches directories in GNU quilt format')
  189. merge_parser.add_argument(
  190. '--prepend',
  191. '-p',
  192. action='store_true',
  193. help=('If "destination" exists, prepend patches from sources into it.'
  194. ' By default, merging will fail if the destination already exists.'))
  195. merge_parser.add_argument(
  196. 'destination',
  197. type=Path,
  198. help=('The directory to write the merged patches to. '
  199. 'The destination must not exist unless --prepend is specified.'))
  200. merge_parser.add_argument(
  201. 'source', type=Path, nargs='+', help='The GNU quilt patches to merge.')
  202. merge_parser.set_defaults(callback=_merge_callback)
  203. args = parser.parse_args()
  204. if 'callback' not in args:
  205. parser.error('Must specify subcommand apply or merge')
  206. args.callback(args, parser.error)
  207. if __name__ == '__main__':
  208. main()