xref: /aosp_15_r20/external/toolchain-utils/llvm_tools/patch_manager.py (revision 760c253c1ed00ce9abd48f8546f08516e57485fe)
1#!/usr/bin/env python3
2# Copyright 2019 The ChromiumOS Authors
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6"""A manager for patches."""
7
8import argparse
9import enum
10import os
11from pathlib import Path
12import sys
13from typing import Callable, Iterable, List, Optional, Tuple
14
15import failure_modes
16import get_llvm_hash
17import patch_utils
18import subprocess_helpers
19
20
21class GitBisectionCode(enum.IntEnum):
22    """Git bisection exit codes.
23
24    Used when patch_manager.py is in the bisection mode,
25    as we need to return in what way we should handle
26    certain patch failures.
27    """
28
29    GOOD = 0
30    """All patches applied successfully."""
31    BAD = 1
32    """The tested patch failed to apply."""
33    SKIP = 125
34
35
36def GetCommandLineArgs(sys_argv: Optional[List[str]]):
37    """Get the required arguments from the command line."""
38
39    # Create parser and add optional command-line arguments.
40    parser = argparse.ArgumentParser(description="A manager for patches.")
41
42    # Add argument for the LLVM version to use for patch management.
43    parser.add_argument(
44        "--svn_version",
45        type=int,
46        help="the LLVM svn version to use for patch management (determines "
47        "whether a patch is applicable). Required when not bisecting.",
48    )
49
50    # Add argument for the patch metadata file that is in $FILESDIR.
51    parser.add_argument(
52        "--patch_metadata_file",
53        required=True,
54        type=Path,
55        help='the absolute path to the .json file in "$FILESDIR/" of the '
56        "package which has all the patches and their metadata if applicable",
57    )
58
59    # Add argument for the absolute path to the unpacked sources.
60    parser.add_argument(
61        "--src_path",
62        required=True,
63        type=Path,
64        help="the absolute path to the unpacked LLVM sources",
65    )
66
67    # Add argument for the mode of the patch manager when handling failing
68    # applicable patches.
69    parser.add_argument(
70        "--failure_mode",
71        default=failure_modes.FailureModes.FAIL,
72        type=failure_modes.FailureModes,
73        help="the mode of the patch manager when handling failed patches "
74        "(default: %(default)s)",
75    )
76    parser.add_argument(
77        "--test_patch",
78        default="",
79        help="The rel_patch_path of the patch we want to bisect the "
80        "application of. Not used in other modes.",
81    )
82
83    # Add argument for the option to us git am to commit patch or
84    # just using patch.
85    parser.add_argument(
86        "--git_am",
87        action="store_true",
88        help="If set, use 'git am' to patch instead of GNU 'patch'. ",
89    )
90
91    # Parse the command line.
92    return parser.parse_args(sys_argv)
93
94
95def GetHEADSVNVersion(src_path):
96    """Gets the SVN version of HEAD in the src tree."""
97    git_hash = subprocess_helpers.check_output(
98        ["git", "-C", src_path, "rev-parse", "HEAD"]
99    )
100    return get_llvm_hash.GetVersionFrom(src_path, git_hash.rstrip())
101
102
103def GetCommitHashesForBisection(src_path, good_svn_version, bad_svn_version):
104    """Gets the good and bad commit hashes required by `git bisect start`."""
105
106    bad_commit_hash = get_llvm_hash.GetGitHashFrom(src_path, bad_svn_version)
107
108    good_commit_hash = get_llvm_hash.GetGitHashFrom(src_path, good_svn_version)
109
110    return good_commit_hash, bad_commit_hash
111
112
113def CheckPatchApplies(
114    svn_version: int,
115    llvm_src_dir: Path,
116    patches_json_fp: Path,
117    rel_patch_path: str,
118) -> GitBisectionCode:
119    """Check that a given patch with the rel_patch_path applies in the stack.
120
121    This is used in the bisection mode of the patch manager. It's similiar
122    to ApplyAllFromJson, but differs in that the patch with rel_patch_path
123    will attempt to apply regardless of its version range, as we're trying
124    to identify the SVN version
125
126    Args:
127        svn_version: SVN version to test at.
128        llvm_src_dir: llvm-project source code diroctory (with a .git).
129        patches_json_fp: PATCHES.json filepath.
130        rel_patch_path: Relative patch path of the patch we want to check. If
131          patches before this patch fail to apply, then the revision is
132          skipped.
133    """
134    with patches_json_fp.open(encoding="utf-8") as f:
135        patch_entries = patch_utils.json_to_patch_entries(
136            patches_json_fp.parent,
137            f,
138        )
139    with patch_utils.git_clean_context(llvm_src_dir):
140        success, _, failed_patches = ApplyPatchAndPrior(
141            svn_version,
142            llvm_src_dir,
143            patch_entries,
144            rel_patch_path,
145            patch_utils.git_am,
146        )
147    if success:
148        # Everything is good, patch applied successfully.
149        print(f"SUCCEEDED applying {rel_patch_path} @ r{svn_version}")
150        return GitBisectionCode.GOOD
151    if failed_patches and failed_patches[-1].rel_patch_path == rel_patch_path:
152        # We attempted to apply this patch, but it failed.
153        print(f"FAILED to apply {rel_patch_path} @ r{svn_version}")
154        return GitBisectionCode.BAD
155    # Didn't attempt to apply the patch, but failed regardless.
156    # Skip this revision.
157    print(f"SKIPPED {rel_patch_path} @ r{svn_version} due to prior failures")
158    return GitBisectionCode.SKIP
159
160
161def ApplyPatchAndPrior(
162    svn_version: int,
163    src_dir: Path,
164    patch_entries: Iterable[patch_utils.PatchEntry],
165    rel_patch_path: str,
166    patch_cmd: Optional[Callable] = None,
167) -> Tuple[bool, List[patch_utils.PatchEntry], List[patch_utils.PatchEntry]]:
168    """Apply a patch, and all patches that apply before it in the patch stack.
169
170    Patches which did not attempt to apply (because their version range didn't
171    match and they weren't the patch of interest) do not appear in the output.
172
173    Probably shouldn't be called from outside of CheckPatchApplies, as it
174    modifies the source dir contents.
175
176    Returns:
177        A tuple where:
178            [0]: Did the patch of interest succeed in applying?
179            [1]: List of applied patches, potentially containing the patch of
180            interest.
181            [2]: List of failing patches, potentially containing the patch of
182            interest.
183    """
184    failed_patches: List[patch_utils.PatchEntry] = []
185    applied_patches = []
186    # We have to apply every patch up to the one we care about,
187    # as patches can stack.
188    for pe in patch_entries:
189        is_patch_of_interest = pe.rel_patch_path == rel_patch_path
190        applied, failed_hunks = patch_utils.apply_single_patch_entry(
191            svn_version,
192            src_dir,
193            pe,
194            patch_cmd,
195            ignore_version_range=is_patch_of_interest,
196        )
197        meant_to_apply = bool(failed_hunks) or is_patch_of_interest
198        if is_patch_of_interest:
199            if applied:
200                # We applied the patch we wanted to, we can stop.
201                applied_patches.append(pe)
202                return True, applied_patches, failed_patches
203            else:
204                # We failed the patch we cared about, we can stop.
205                failed_patches.append(pe)
206                return False, applied_patches, failed_patches
207        else:
208            if applied:
209                applied_patches.append(pe)
210            elif meant_to_apply:
211                # Broke before we reached the patch we cared about. Stop.
212                failed_patches.append(pe)
213                return False, applied_patches, failed_patches
214    raise ValueError(f"Did not find patch {rel_patch_path}. " "Does it exist?")
215
216
217def PrintPatchResults(patch_info: patch_utils.PatchInfo):
218    """Prints the results of handling the patches of a package.
219
220    Args:
221        patch_info: A dataclass that has information on the patches.
222    """
223
224    def _fmt(patches):
225        return (str(pe.patch_path()) for pe in patches)
226
227    if patch_info.applied_patches:
228        print("\nThe following patches applied successfully:")
229        print("\n".join(_fmt(patch_info.applied_patches)))
230
231    if patch_info.failed_patches:
232        print("\nThe following patches failed to apply:")
233        print("\n".join(_fmt(patch_info.failed_patches)))
234
235    if patch_info.non_applicable_patches:
236        print("\nThe following patches were not applicable:")
237        print("\n".join(_fmt(patch_info.non_applicable_patches)))
238
239    if patch_info.modified_metadata:
240        print(
241            "\nThe patch metadata file %s has been modified"
242            % os.path.basename(patch_info.modified_metadata)
243        )
244
245    if patch_info.disabled_patches:
246        print("\nThe following patches were disabled:")
247        print("\n".join(_fmt(patch_info.disabled_patches)))
248
249    if patch_info.removed_patches:
250        print(
251            "\nThe following patches were removed from the patch metadata file:"
252        )
253        for cur_patch_path in patch_info.removed_patches:
254            print("%s" % os.path.basename(cur_patch_path))
255
256
257def main(sys_argv: List[str]):
258    """Applies patches to the source tree and takes action on a failed patch."""
259
260    args_output = GetCommandLineArgs(sys_argv)
261
262    llvm_src_dir = Path(args_output.src_path)
263    if not llvm_src_dir.is_dir():
264        raise ValueError(f"--src_path arg {llvm_src_dir} is not a directory")
265    patches_json_fp = Path(args_output.patch_metadata_file)
266    if not patches_json_fp.is_file():
267        raise ValueError(
268            "--patch_metadata_file arg " f"{patches_json_fp} is not a file"
269        )
270
271    def _apply_all(args):
272        if args.svn_version is None:
273            raise ValueError("--svn_version must be set when applying patches")
274        result = patch_utils.apply_all_from_json(
275            svn_version=args.svn_version,
276            llvm_src_dir=llvm_src_dir,
277            patches_json_fp=patches_json_fp,
278            patch_cmd=patch_utils.git_am
279            if args.git_am
280            else patch_utils.gnu_patch,
281            continue_on_failure=args.failure_mode
282            == failure_modes.FailureModes.CONTINUE,
283        )
284        PrintPatchResults(result)
285
286    def _disable(args):
287        patch_cmd = patch_utils.git_am if args.git_am else patch_utils.gnu_patch
288        patch_utils.update_version_ranges(
289            args.svn_version, llvm_src_dir, patches_json_fp, patch_cmd
290        )
291
292    def _test_single(args):
293        if not args.test_patch:
294            raise ValueError(
295                "Running with bisect_patches requires the " "--test_patch flag."
296            )
297        svn_version = GetHEADSVNVersion(llvm_src_dir)
298        error_code = CheckPatchApplies(
299            svn_version,
300            llvm_src_dir,
301            patches_json_fp,
302            args.test_patch,
303        )
304        # Since this is for bisection, we want to exit with the
305        # GitBisectionCode enum.
306        sys.exit(int(error_code))
307
308    dispatch_table = {
309        failure_modes.FailureModes.FAIL: _apply_all,
310        failure_modes.FailureModes.CONTINUE: _apply_all,
311        failure_modes.FailureModes.DISABLE_PATCHES: _disable,
312        failure_modes.FailureModes.BISECT_PATCHES: _test_single,
313    }
314
315    if args_output.failure_mode in dispatch_table:
316        dispatch_table[args_output.failure_mode](args_output)
317
318
319if __name__ == "__main__":
320    main(sys.argv[1:])
321