aboutsummaryrefslogtreecommitdiff
path: root/llvm_tools/patch_manager.py
blob: 2893d61160230d3998615a25cc92f4349594eac6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
#!/usr/bin/env python3
# Copyright 2019 The ChromiumOS Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

"""A manager for patches."""

import argparse
import enum
import json
import os
from pathlib import Path
import sys
from typing import Any, Dict, IO, Iterable, List, Optional, Tuple

from failure_modes import FailureModes
import get_llvm_hash
import patch_utils
from subprocess_helpers import check_output


class GitBisectionCode(enum.IntEnum):
  """Git bisection exit codes.

  Used when patch_manager.py is in the bisection mode,
  as we need to return in what way we should handle
  certain patch failures.
  """
  GOOD = 0
  """All patches applied successfully."""
  BAD = 1
  """The tested patch failed to apply."""
  SKIP = 125


def GetCommandLineArgs(sys_argv: Optional[List[str]]):
  """Get the required arguments from the command line."""

  # Create parser and add optional command-line arguments.
  parser = argparse.ArgumentParser(description='A manager for patches.')

  # Add argument for the LLVM version to use for patch management.
  parser.add_argument(
      '--svn_version',
      type=int,
      help='the LLVM svn version to use for patch management (determines '
      'whether a patch is applicable). Required when not bisecting.')

  # Add argument for the patch metadata file that is in $FILESDIR.
  parser.add_argument(
      '--patch_metadata_file',
      required=True,
      type=Path,
      help='the absolute path to the .json file in "$FILESDIR/" of the '
      'package which has all the patches and their metadata if applicable')

  # Add argument for the absolute path to the unpacked sources.
  parser.add_argument('--src_path',
                      required=True,
                      type=Path,
                      help='the absolute path to the unpacked LLVM sources')

  # Add argument for the mode of the patch manager when handling failing
  # applicable patches.
  parser.add_argument(
      '--failure_mode',
      default=FailureModes.FAIL,
      type=FailureModes,
      help='the mode of the patch manager when handling failed patches '
      '(default: %(default)s)')
  parser.add_argument(
      '--test_patch',
      default='',
      help='The rel_patch_path of the patch we want to bisect the '
      'application of. Not used in other modes.')

  # Parse the command line.
  return parser.parse_args(sys_argv)


def GetHEADSVNVersion(src_path):
  """Gets the SVN version of HEAD in the src tree."""

  cmd = ['git', '-C', src_path, 'rev-parse', 'HEAD']

  git_hash = check_output(cmd)

  version = get_llvm_hash.GetVersionFrom(src_path, git_hash.rstrip())

  return version


def _WriteJsonChanges(patches: List[Dict[str, Any]], file_io: IO[str]):
  """Write JSON changes to file, does not acquire new file lock."""
  json.dump(patches, file_io, indent=4, separators=(',', ': '))
  # Need to add a newline as json.dump omits it.
  file_io.write('\n')


def GetCommitHashesForBisection(src_path, good_svn_version, bad_svn_version):
  """Gets the good and bad commit hashes required by `git bisect start`."""

  bad_commit_hash = get_llvm_hash.GetGitHashFrom(src_path, bad_svn_version)

  good_commit_hash = get_llvm_hash.GetGitHashFrom(src_path, good_svn_version)

  return good_commit_hash, bad_commit_hash


def RemoveOldPatches(svn_version: int, llvm_src_dir: Path,
                     patches_json_fp: Path):
  """Remove patches that don't and will never apply for the future.

  Patches are determined to be "old" via the "is_old" method for
  each patch entry.

  Args:
    svn_version: LLVM SVN version.
    llvm_src_dir: LLVM source directory.
    patches_json_fp: Location to edit patches on.
  """
  with patches_json_fp.open(encoding='utf-8') as f:
    patches_list = json.load(f)
  patch_entries = (patch_utils.PatchEntry.from_dict(llvm_src_dir, elem)
                   for elem in patches_list)
  oldness = [(entry, entry.is_old(svn_version)) for entry in patch_entries]
  filtered_entries = [entry.to_dict() for entry, old in oldness if not old]
  with patch_utils.atomic_write(patches_json_fp, encoding='utf-8') as f:
    _WriteJsonChanges(filtered_entries, f)
  removed_entries = [entry for entry, old in oldness if old]
  plural_patches = 'patch' if len(removed_entries) == 1 else 'patches'
  print(f'Removed {len(removed_entries)} old {plural_patches}:')
  for r in removed_entries:
    print(f'- {r.rel_patch_path}: {r.title()}')


def UpdateVersionRanges(svn_version: int, llvm_src_dir: Path,
                        patches_json_fp: Path):
  """Reduce the version ranges of failing patches.

  Patches which fail to apply will have their 'version_range.until'
  field reduced to the passed in svn_version.

  Modifies the contents of patches_json_fp.

  Ars:
    svn_version: LLVM revision number.
    llvm_src_dir: llvm-project directory path.
    patches_json_fp: Filepath to the PATCHES.json file.
  """
  with patches_json_fp.open(encoding='utf-8') as f:
    patch_entries = patch_utils.json_to_patch_entries(
        patches_json_fp.parent,
        f,
    )
  modified_entries = UpdateVersionRangesWithEntries(svn_version, llvm_src_dir,
                                                    patch_entries)
  with patch_utils.atomic_write(patches_json_fp, encoding='utf-8') as f:
    _WriteJsonChanges([p.to_dict() for p in patch_entries], f)
  for entry in modified_entries:
    print(f'Stopped applying {entry.rel_patch_path} ({entry.title()}) '
          f'for r{svn_version}')


def UpdateVersionRangesWithEntries(
    svn_version: int, llvm_src_dir: Path,
    patch_entries: Iterable[patch_utils.PatchEntry]
) -> List[patch_utils.PatchEntry]:
  """Test-able helper for UpdateVersionRanges.

  Args:
    svn_version: LLVM revision number.
    llvm_src_dir: llvm-project directory path.
    patch_entries: PatchEntry objects to modify.

  Returns:
    A list of PatchEntry objects which were modified.

  Post:
    Modifies patch_entries in place.
  """
  modified_entries: List[patch_utils.PatchEntry] = []
  with patch_utils.git_clean_context(llvm_src_dir):
    for pe in patch_entries:
      test_result = pe.test_apply(llvm_src_dir)
      if not test_result:
        if pe.version_range is None:
          pe.version_range = {}
        pe.version_range['until'] = svn_version
        modified_entries.append(pe)
      else:
        # We have to actually apply the patch so that future patches
        # will stack properly.
        if not pe.apply(llvm_src_dir).succeeded:
          raise RuntimeError('Could not apply patch that dry ran successfully')
  return modified_entries


def CheckPatchApplies(svn_version: int, llvm_src_dir: Path,
                      patches_json_fp: Path,
                      rel_patch_path: str) -> GitBisectionCode:
  """Check that a given patch with the rel_patch_path applies in the stack.

  This is used in the bisection mode of the patch manager. It's similiar
  to ApplyAllFromJson, but differs in that the patch with rel_patch_path
  will attempt to apply regardless of its version range, as we're trying
  to identify the SVN version

  Args:
    svn_version: SVN version to test at.
    llvm_src_dir: llvm-project source code diroctory (with a .git).
    patches_json_fp: PATCHES.json filepath.
    rel_patch_path: Relative patch path of the patch we want to check. If
      patches before this patch fail to apply, then the revision is skipped.
  """
  with patches_json_fp.open(encoding='utf-8') as f:
    patch_entries = patch_utils.json_to_patch_entries(
        patches_json_fp.parent,
        f,
    )
  with patch_utils.git_clean_context(llvm_src_dir):
    success, _, failed_patches = ApplyPatchAndPrior(
        svn_version,
        llvm_src_dir,
        patch_entries,
        rel_patch_path,
    )
  if success:
    # Everything is good, patch applied successfully.
    print(f'SUCCEEDED applying {rel_patch_path} @ r{svn_version}')
    return GitBisectionCode.GOOD
  if failed_patches and failed_patches[-1].rel_patch_path == rel_patch_path:
    # We attempted to apply this patch, but it failed.
    print(f'FAILED to apply {rel_patch_path} @ r{svn_version}')
    return GitBisectionCode.BAD
  # Didn't attempt to apply the patch, but failed regardless.
  # Skip this revision.
  print(f'SKIPPED {rel_patch_path} @ r{svn_version} due to prior failures')
  return GitBisectionCode.SKIP


def ApplyPatchAndPrior(
    svn_version: int, src_dir: Path,
    patch_entries: Iterable[patch_utils.PatchEntry], rel_patch_path: str
) -> Tuple[bool, List[patch_utils.PatchEntry], List[patch_utils.PatchEntry]]:
  """Apply a patch, and all patches that apply before it in the patch stack.

  Patches which did not attempt to apply (because their version range didn't
  match and they weren't the patch of interest) do not appear in the output.

  Probably shouldn't be called from outside of CheckPatchApplies, as it modifies
  the source dir contents.

  Returns:
    A tuple where:
    [0]: Did the patch of interest succeed in applying?
    [1]: List of applied patches, potentially containing the patch of interest.
    [2]: List of failing patches, potentially containing the patch of interest.
  """
  failed_patches = []
  applied_patches = []
  # We have to apply every patch up to the one we care about,
  # as patches can stack.
  for pe in patch_entries:
    is_patch_of_interest = pe.rel_patch_path == rel_patch_path
    applied, failed_hunks = patch_utils.apply_single_patch_entry(
        svn_version, src_dir, pe, ignore_version_range=is_patch_of_interest)
    meant_to_apply = bool(failed_hunks) or is_patch_of_interest
    if is_patch_of_interest:
      if applied:
        # We applied the patch we wanted to, we can stop.
        applied_patches.append(pe)
        return True, applied_patches, failed_patches
      else:
        # We failed the patch we cared about, we can stop.
        failed_patches.append(pe)
        return False, applied_patches, failed_patches
    else:
      if applied:
        applied_patches.append(pe)
      elif meant_to_apply:
        # Broke before we reached the patch we cared about. Stop.
        failed_patches.append(pe)
        return False, applied_patches, failed_patches
  raise ValueError(f'Did not find patch {rel_patch_path}. '
                   'Does it exist?')


def PrintPatchResults(patch_info: patch_utils.PatchInfo):
  """Prints the results of handling the patches of a package.

  Args:
    patch_info: A dataclass that has information on the patches.
  """

  def _fmt(patches):
    return (str(pe.patch_path()) for pe in patches)

  if patch_info.applied_patches:
    print('\nThe following patches applied successfully:')
    print('\n'.join(_fmt(patch_info.applied_patches)))

  if patch_info.failed_patches:
    print('\nThe following patches failed to apply:')
    print('\n'.join(_fmt(patch_info.failed_patches)))

  if patch_info.non_applicable_patches:
    print('\nThe following patches were not applicable:')
    print('\n'.join(_fmt(patch_info.non_applicable_patches)))

  if patch_info.modified_metadata:
    print('\nThe patch metadata file %s has been modified' %
          os.path.basename(patch_info.modified_metadata))

  if patch_info.disabled_patches:
    print('\nThe following patches were disabled:')
    print('\n'.join(_fmt(patch_info.disabled_patches)))

  if patch_info.removed_patches:
    print('\nThe following patches were removed from the patch metadata file:')
    for cur_patch_path in patch_info.removed_patches:
      print('%s' % os.path.basename(cur_patch_path))


def main(sys_argv: List[str]):
  """Applies patches to the source tree and takes action on a failed patch."""

  args_output = GetCommandLineArgs(sys_argv)

  llvm_src_dir = Path(args_output.src_path)
  if not llvm_src_dir.is_dir():
    raise ValueError(f'--src_path arg {llvm_src_dir} is not a directory')
  patches_json_fp = Path(args_output.patch_metadata_file)
  if not patches_json_fp.is_file():
    raise ValueError('--patch_metadata_file arg '
                     f'{patches_json_fp} is not a file')

  def _apply_all(args):
    if args.svn_version is None:
      raise ValueError('--svn_version must be set when applying patches')
    result = patch_utils.apply_all_from_json(
        svn_version=args.svn_version,
        llvm_src_dir=llvm_src_dir,
        patches_json_fp=patches_json_fp,
        continue_on_failure=args.failure_mode == FailureModes.CONTINUE)
    PrintPatchResults(result)

  def _remove(args):
    RemoveOldPatches(args.svn_version, llvm_src_dir, patches_json_fp)

  def _disable(args):
    UpdateVersionRanges(args.svn_version, llvm_src_dir, patches_json_fp)

  def _test_single(args):
    if not args.test_patch:
      raise ValueError('Running with bisect_patches requires the '
                       '--test_patch flag.')
    svn_version = GetHEADSVNVersion(llvm_src_dir)
    error_code = CheckPatchApplies(svn_version, llvm_src_dir, patches_json_fp,
                                   args.test_patch)
    # Since this is for bisection, we want to exit with the
    # GitBisectionCode enum.
    sys.exit(int(error_code))

  dispatch_table = {
      FailureModes.FAIL: _apply_all,
      FailureModes.CONTINUE: _apply_all,
      FailureModes.REMOVE_PATCHES: _remove,
      FailureModes.DISABLE_PATCHES: _disable,
      FailureModes.BISECT_PATCHES: _test_single,
  }

  if args_output.failure_mode in dispatch_table:
    dispatch_table[args_output.failure_mode](args_output)


if __name__ == '__main__':
  main(sys.argv[1:])