xref: /aosp_15_r20/external/toolchain-utils/llvm_tools/get_patch.py (revision 760c253c1ed00ce9abd48f8546f08516e57485fe)
1*760c253cSXin Li#!/usr/bin/env python3
2*760c253cSXin Li# Copyright 2024 The ChromiumOS Authors
3*760c253cSXin Li# Use of this source code is governed by a BSD-style license that can be
4*760c253cSXin Li# found in the LICENSE file.
5*760c253cSXin Li
6*760c253cSXin Li"""Get patches from a patch source, and integrate them into ChromiumOS.
7*760c253cSXin Li
8*760c253cSXin LiExample Usage:
9*760c253cSXin Li    # Apply a Pull request.
10*760c253cSXin Li    $ get_patch.py -s HEAD p:74791
11*760c253cSXin Li    # Apply several patches.
12*760c253cSXin Li    $ get_patch.py -s 82e851a407c5 p:74791 47413bb27
13*760c253cSXin Li    # Use another llvm-project dir.
14*760c253cSXin Li    $ get_patch.py -s HEAD -l ~/llvm-project 47413bb27
15*760c253cSXin Li"""
16*760c253cSXin Li
17*760c253cSXin Liimport argparse
18*760c253cSXin Liimport dataclasses
19*760c253cSXin Liimport json
20*760c253cSXin Liimport logging
21*760c253cSXin Lifrom pathlib import Path
22*760c253cSXin Liimport random
23*760c253cSXin Liimport re
24*760c253cSXin Liimport subprocess
25*760c253cSXin Liimport tempfile
26*760c253cSXin Liimport textwrap
27*760c253cSXin Lifrom typing import Any, Dict, Iterable, List, Optional, Set, Tuple, Union
28*760c253cSXin Lifrom urllib import request
29*760c253cSXin Li
30*760c253cSXin Liimport atomic_write_file
31*760c253cSXin Liimport git_llvm_rev
32*760c253cSXin Liimport patch_utils
33*760c253cSXin Li
34*760c253cSXin Li
35*760c253cSXin LiCHROMIUMOS_OVERLAY_PATH = Path("src/third_party/chromiumos-overlay")
36*760c253cSXin LiLLVM_PKG_PATH = CHROMIUMOS_OVERLAY_PATH / "sys-devel/llvm"
37*760c253cSXin LiCOMPILER_RT_PKG_PATH = CHROMIUMOS_OVERLAY_PATH / "sys-libs/compiler-rt"
38*760c253cSXin LiLIBCXX_PKG_PATH = CHROMIUMOS_OVERLAY_PATH / "sys-libs/libcxx"
39*760c253cSXin LiLIBUNWIND_PKG_PATH = CHROMIUMOS_OVERLAY_PATH / "sys-libs/llvm-libunwind"
40*760c253cSXin LiSCUDO_PKG_PATH = CHROMIUMOS_OVERLAY_PATH / "sys-libs/scudo"
41*760c253cSXin LiLLDB_PKG_PATH = CHROMIUMOS_OVERLAY_PATH / "dev-util/lldb-server"
42*760c253cSXin Li
43*760c253cSXin LiLLVM_PROJECT_PATH = Path("src/third_party/llvm-project")
44*760c253cSXin LiPATCH_METADATA_FILENAME = "PATCHES.json"
45*760c253cSXin Li
46*760c253cSXin Li
47*760c253cSXin Liclass CherrypickError(ValueError):
48*760c253cSXin Li    """ValueError for a cherry-pick has been seen before."""
49*760c253cSXin Li
50*760c253cSXin Li
51*760c253cSXin Liclass CherrypickVersionError(ValueError):
52*760c253cSXin Li    """ValueError that highlights the cherry-pick is before the start_ref."""
53*760c253cSXin Li
54*760c253cSXin Li
55*760c253cSXin Li@dataclasses.dataclass
56*760c253cSXin Liclass LLVMGitRef:
57*760c253cSXin Li    """Represents an LLVM git ref."""
58*760c253cSXin Li
59*760c253cSXin Li    git_ref: str
60*760c253cSXin Li    _rev: Optional[git_llvm_rev.Rev] = None  # Used for caching
61*760c253cSXin Li
62*760c253cSXin Li    @classmethod
63*760c253cSXin Li    def from_rev(cls, llvm_dir: Path, rev: git_llvm_rev.Rev) -> "LLVMGitRef":
64*760c253cSXin Li        return cls(
65*760c253cSXin Li            git_llvm_rev.translate_rev_to_sha(
66*760c253cSXin Li                git_llvm_rev.LLVMConfig("origin", llvm_dir), rev
67*760c253cSXin Li            ),
68*760c253cSXin Li            _rev=rev,
69*760c253cSXin Li        )
70*760c253cSXin Li
71*760c253cSXin Li    def to_rev(self, llvm_dir: Path) -> git_llvm_rev.Rev:
72*760c253cSXin Li        if self._rev:
73*760c253cSXin Li            return self._rev
74*760c253cSXin Li        self._rev = git_llvm_rev.translate_sha_to_rev(
75*760c253cSXin Li            git_llvm_rev.LLVMConfig("origin", llvm_dir),
76*760c253cSXin Li            self.git_ref,
77*760c253cSXin Li        )
78*760c253cSXin Li        return self._rev
79*760c253cSXin Li
80*760c253cSXin Li
81*760c253cSXin Li@dataclasses.dataclass(frozen=True)
82*760c253cSXin Liclass LLVMPullRequest:
83*760c253cSXin Li    """Represents an upstream GitHub Pull Request number."""
84*760c253cSXin Li
85*760c253cSXin Li    number: int
86*760c253cSXin Li
87*760c253cSXin Li
88*760c253cSXin Li@dataclasses.dataclass
89*760c253cSXin Liclass PatchContext:
90*760c253cSXin Li    """Represents the state of the chromiumos source during patching."""
91*760c253cSXin Li
92*760c253cSXin Li    llvm_project_dir: Path
93*760c253cSXin Li    chromiumos_root: Path
94*760c253cSXin Li    start_ref: LLVMGitRef
95*760c253cSXin Li    platforms: Iterable[str]
96*760c253cSXin Li    dry_run: bool = False
97*760c253cSXin Li
98*760c253cSXin Li    def apply_patches(
99*760c253cSXin Li        self, patch_source: Union[LLVMGitRef, LLVMPullRequest]
100*760c253cSXin Li    ) -> None:
101*760c253cSXin Li        """Create .patch files and add them to PATCHES.json.
102*760c253cSXin Li
103*760c253cSXin Li        Post:
104*760c253cSXin Li            Unless self.dry_run is True, writes the patch contents to
105*760c253cSXin Li            the respective <pkg>/files/ workdir for each applicable
106*760c253cSXin Li            patch, and the JSON files are updated with the new entries.
107*760c253cSXin Li
108*760c253cSXin Li        Raises:
109*760c253cSXin Li            TypeError: If the patch_source is not a
110*760c253cSXin Li                LLVMGitRef or LLVMPullRequest.
111*760c253cSXin Li        """
112*760c253cSXin Li        new_patch_entries = self.make_patches(patch_source)
113*760c253cSXin Li        self.apply_entries_to_json(new_patch_entries)
114*760c253cSXin Li
115*760c253cSXin Li    def apply_entries_to_json(
116*760c253cSXin Li        self,
117*760c253cSXin Li        new_patch_entries: Iterable[patch_utils.PatchEntry],
118*760c253cSXin Li    ) -> None:
119*760c253cSXin Li        """Add some PatchEntries to the appropriate PATCHES.json."""
120*760c253cSXin Li        workdir_mappings: Dict[Path, List[patch_utils.PatchEntry]] = {}
121*760c253cSXin Li        for pe in new_patch_entries:
122*760c253cSXin Li            workdir_mappings[pe.workdir] = workdir_mappings.get(
123*760c253cSXin Li                pe.workdir, []
124*760c253cSXin Li            ) + [pe]
125*760c253cSXin Li        for workdir, pes in workdir_mappings.items():
126*760c253cSXin Li            patches_json_file = workdir / PATCH_METADATA_FILENAME
127*760c253cSXin Li            with patches_json_file.open(encoding="utf-8") as f:
128*760c253cSXin Li                orig_contents = f.read()
129*760c253cSXin Li            old_patch_entries = patch_utils.json_str_to_patch_entries(
130*760c253cSXin Li                workdir, orig_contents
131*760c253cSXin Li            )
132*760c253cSXin Li            indent_len = patch_utils.predict_indent(orig_contents.splitlines())
133*760c253cSXin Li            if not self.dry_run:
134*760c253cSXin Li                with atomic_write_file.atomic_write(
135*760c253cSXin Li                    patches_json_file, encoding="utf-8"
136*760c253cSXin Li                ) as f:
137*760c253cSXin Li                    json.dump(
138*760c253cSXin Li                        [pe.to_dict() for pe in old_patch_entries + pes],
139*760c253cSXin Li                        f,
140*760c253cSXin Li                        indent=indent_len,
141*760c253cSXin Li                    )
142*760c253cSXin Li                    f.write("\n")
143*760c253cSXin Li
144*760c253cSXin Li    def make_patches(
145*760c253cSXin Li        self, patch_source: Union[LLVMGitRef, LLVMPullRequest]
146*760c253cSXin Li    ) -> List[patch_utils.PatchEntry]:
147*760c253cSXin Li        """Create PatchEntries for a given LLVM change and returns them.
148*760c253cSXin Li
149*760c253cSXin Li        Returns:
150*760c253cSXin Li            A list of PatchEntries representing the patches for each
151*760c253cSXin Li            package for the given patch_source.
152*760c253cSXin Li
153*760c253cSXin Li        Post:
154*760c253cSXin Li            Unless self.dry_run is True, writes the patch contents to
155*760c253cSXin Li            the respective <pkg>/files/ workdir for each applicable
156*760c253cSXin Li            patch.
157*760c253cSXin Li
158*760c253cSXin Li        Raises:
159*760c253cSXin Li            TypeError: If the patch_source is not a
160*760c253cSXin Li                LLVMGitRef or LLVMPullRequest.
161*760c253cSXin Li        """
162*760c253cSXin Li
163*760c253cSXin Li        # This is just a dispatch method to the actual methods.
164*760c253cSXin Li        if isinstance(patch_source, LLVMGitRef):
165*760c253cSXin Li            return self._make_patches_from_git_ref(patch_source)
166*760c253cSXin Li        if isinstance(patch_source, LLVMPullRequest):
167*760c253cSXin Li            return self._make_patches_from_pr(patch_source)
168*760c253cSXin Li        raise TypeError(
169*760c253cSXin Li            f"patch_source was invalid type {type(patch_source).__name__}"
170*760c253cSXin Li        )
171*760c253cSXin Li
172*760c253cSXin Li    def _make_patches_from_git_ref(
173*760c253cSXin Li        self,
174*760c253cSXin Li        patch_source: LLVMGitRef,
175*760c253cSXin Li    ) -> List[patch_utils.PatchEntry]:
176*760c253cSXin Li        packages = get_changed_packages(
177*760c253cSXin Li            self.llvm_project_dir, patch_source.git_ref
178*760c253cSXin Li        )
179*760c253cSXin Li        new_patch_entries: List[patch_utils.PatchEntry] = []
180*760c253cSXin Li        for workdir in self._workdirs_for_packages(packages):
181*760c253cSXin Li            rel_patch_path = f"cherry/{patch_source.git_ref}.patch"
182*760c253cSXin Li            if (workdir / "cherry").is_dir():
183*760c253cSXin Li                rel_patch_path = f"cherry/{patch_source.git_ref}.patch"
184*760c253cSXin Li            else:
185*760c253cSXin Li                # Some packages don't have a cherry directory.
186*760c253cSXin Li                rel_patch_path = f"{patch_source.git_ref}.patch"
187*760c253cSXin Li            if not self._is_valid_patch_range(self.start_ref, patch_source):
188*760c253cSXin Li                raise CherrypickVersionError(
189*760c253cSXin Li                    f"'from' ref {self.start_ref} is later or"
190*760c253cSXin Li                    f" same as than 'until' ref {patch_source}"
191*760c253cSXin Li                )
192*760c253cSXin Li            pe = patch_utils.PatchEntry(
193*760c253cSXin Li                workdir=workdir,
194*760c253cSXin Li                metadata={
195*760c253cSXin Li                    "title": get_commit_subj(
196*760c253cSXin Li                        self.llvm_project_dir, patch_source.git_ref
197*760c253cSXin Li                    ),
198*760c253cSXin Li                    "info": [],
199*760c253cSXin Li                },
200*760c253cSXin Li                platforms=list(self.platforms),
201*760c253cSXin Li                rel_patch_path=rel_patch_path,
202*760c253cSXin Li                version_range={
203*760c253cSXin Li                    "from": self.start_ref.to_rev(self.llvm_project_dir).number,
204*760c253cSXin Li                    "until": patch_source.to_rev(self.llvm_project_dir).number,
205*760c253cSXin Li                },
206*760c253cSXin Li            )
207*760c253cSXin Li            # Before we actually do any modifications, check if the patch is
208*760c253cSXin Li            # already applied.
209*760c253cSXin Li            if self.is_patch_applied(pe):
210*760c253cSXin Li                raise CherrypickError(
211*760c253cSXin Li                    f"Patch at {pe.rel_patch_path}"
212*760c253cSXin Li                    " already exists in PATCHES.json"
213*760c253cSXin Li                )
214*760c253cSXin Li            contents = git_format_patch(
215*760c253cSXin Li                self.llvm_project_dir,
216*760c253cSXin Li                patch_source.git_ref,
217*760c253cSXin Li            )
218*760c253cSXin Li            if not self.dry_run:
219*760c253cSXin Li                _write_patch(pe.title(), contents, pe.patch_path())
220*760c253cSXin Li            new_patch_entries.append(pe)
221*760c253cSXin Li        return new_patch_entries
222*760c253cSXin Li
223*760c253cSXin Li    def _make_patches_from_pr(
224*760c253cSXin Li        self, patch_source: LLVMPullRequest
225*760c253cSXin Li    ) -> List[patch_utils.PatchEntry]:
226*760c253cSXin Li        json_response = get_llvm_github_pull(patch_source.number)
227*760c253cSXin Li        github_ctx = GitHubPRContext(json_response, self.llvm_project_dir)
228*760c253cSXin Li        rel_patch_path = f"{github_ctx.full_title_cleaned}.patch"
229*760c253cSXin Li        contents, packages = github_ctx.git_squash_chain_patch()
230*760c253cSXin Li        new_patch_entries = []
231*760c253cSXin Li        for workdir in self._workdirs_for_packages(packages):
232*760c253cSXin Li            pe = patch_utils.PatchEntry(
233*760c253cSXin Li                workdir=workdir,
234*760c253cSXin Li                metadata={
235*760c253cSXin Li                    "title": github_ctx.full_title,
236*760c253cSXin Li                    "info": [],
237*760c253cSXin Li                },
238*760c253cSXin Li                rel_patch_path=rel_patch_path,
239*760c253cSXin Li                platforms=list(self.platforms),
240*760c253cSXin Li                version_range={
241*760c253cSXin Li                    "from": self.start_ref.to_rev(self.llvm_project_dir).number,
242*760c253cSXin Li                    "until": None,
243*760c253cSXin Li                },
244*760c253cSXin Li            )
245*760c253cSXin Li            # Before we actually do any modifications, check if the patch is
246*760c253cSXin Li            # already applied.
247*760c253cSXin Li            if self.is_patch_applied(pe):
248*760c253cSXin Li                raise CherrypickError(
249*760c253cSXin Li                    f"Patch at {pe.rel_patch_path}"
250*760c253cSXin Li                    " already exists in PATCHES.json"
251*760c253cSXin Li                )
252*760c253cSXin Li            if not self.dry_run:
253*760c253cSXin Li                _write_patch(pe.title(), contents, pe.patch_path())
254*760c253cSXin Li            new_patch_entries.append(pe)
255*760c253cSXin Li        return new_patch_entries
256*760c253cSXin Li
257*760c253cSXin Li    def _workdirs_for_packages(self, packages: Iterable[Path]) -> List[Path]:
258*760c253cSXin Li        return [self.chromiumos_root / pkg / "files" for pkg in packages]
259*760c253cSXin Li
260*760c253cSXin Li    def is_patch_applied(self, to_check: patch_utils.PatchEntry) -> bool:
261*760c253cSXin Li        """Return True if the patch is applied in PATCHES.json."""
262*760c253cSXin Li        patches_json_file = to_check.workdir / PATCH_METADATA_FILENAME
263*760c253cSXin Li        with patches_json_file.open(encoding="utf-8") as f:
264*760c253cSXin Li            patch_entries = patch_utils.json_to_patch_entries(
265*760c253cSXin Li                to_check.workdir, f
266*760c253cSXin Li            )
267*760c253cSXin Li        return any(
268*760c253cSXin Li            p.rel_patch_path == to_check.rel_patch_path for p in patch_entries
269*760c253cSXin Li        )
270*760c253cSXin Li
271*760c253cSXin Li    def _is_valid_patch_range(
272*760c253cSXin Li        self, from_ref: LLVMGitRef, to_ref: LLVMGitRef
273*760c253cSXin Li    ) -> bool:
274*760c253cSXin Li        return (
275*760c253cSXin Li            from_ref.to_rev(self.llvm_project_dir).number
276*760c253cSXin Li            < to_ref.to_rev(self.llvm_project_dir).number
277*760c253cSXin Li        )
278*760c253cSXin Li
279*760c253cSXin Li
280*760c253cSXin Lidef get_commit_subj(git_root_dir: Path, ref: str) -> str:
281*760c253cSXin Li    """Return a given commit's subject."""
282*760c253cSXin Li    logging.debug("Getting commit subject for %s", ref)
283*760c253cSXin Li    subj = subprocess.run(
284*760c253cSXin Li        ["git", "show", "-s", "--format=%s", ref],
285*760c253cSXin Li        cwd=git_root_dir,
286*760c253cSXin Li        encoding="utf-8",
287*760c253cSXin Li        stdout=subprocess.PIPE,
288*760c253cSXin Li        check=True,
289*760c253cSXin Li    ).stdout.strip()
290*760c253cSXin Li    logging.debug("  -> %s", subj)
291*760c253cSXin Li    return subj
292*760c253cSXin Li
293*760c253cSXin Li
294*760c253cSXin Lidef git_format_patch(git_root_dir: Path, ref: str) -> str:
295*760c253cSXin Li    """Format a patch for a single git ref.
296*760c253cSXin Li
297*760c253cSXin Li    Args:
298*760c253cSXin Li        git_root_dir: Root directory for a given local git repository.
299*760c253cSXin Li        ref: Git ref to make a patch for.
300*760c253cSXin Li
301*760c253cSXin Li    Returns:
302*760c253cSXin Li        The patch file contents.
303*760c253cSXin Li    """
304*760c253cSXin Li    logging.debug("Formatting patch for %s^..%s", ref, ref)
305*760c253cSXin Li    proc = subprocess.run(
306*760c253cSXin Li        ["git", "format-patch", "--stdout", f"{ref}^..{ref}"],
307*760c253cSXin Li        cwd=git_root_dir,
308*760c253cSXin Li        encoding="utf-8",
309*760c253cSXin Li        stdout=subprocess.PIPE,
310*760c253cSXin Li        check=True,
311*760c253cSXin Li    )
312*760c253cSXin Li    contents = proc.stdout.strip()
313*760c253cSXin Li    if not contents:
314*760c253cSXin Li        raise ValueError(f"No git diff between {ref}^..{ref}")
315*760c253cSXin Li    logging.debug("Patch diff is %d lines long", contents.count("\n"))
316*760c253cSXin Li    return contents
317*760c253cSXin Li
318*760c253cSXin Li
319*760c253cSXin Lidef get_llvm_github_pull(pull_number: int) -> Dict[str, Any]:
320*760c253cSXin Li    """Get information about an LLVM pull request.
321*760c253cSXin Li
322*760c253cSXin Li    Returns:
323*760c253cSXin Li        A dictionary containing the JSON response from GitHub.
324*760c253cSXin Li
325*760c253cSXin Li    Raises:
326*760c253cSXin Li        RuntimeError when the network response is not OK.
327*760c253cSXin Li    """
328*760c253cSXin Li
329*760c253cSXin Li    pull_url = (
330*760c253cSXin Li        f"https://api.github.com/repos/llvm/llvm-project/pulls/{pull_number}"
331*760c253cSXin Li    )
332*760c253cSXin Li    # TODO(ajordanr): If we are ever allowed to use the 'requests' library
333*760c253cSXin Li    # we should move to that instead of urllib.
334*760c253cSXin Li    req = request.Request(
335*760c253cSXin Li        url=pull_url,
336*760c253cSXin Li        headers={
337*760c253cSXin Li            "X-GitHub-Api-Version": "2022-11-28",
338*760c253cSXin Li            "Accept": "application/vnd.github+json",
339*760c253cSXin Li        },
340*760c253cSXin Li    )
341*760c253cSXin Li    with request.urlopen(req) as f:
342*760c253cSXin Li        if f.status >= 400:
343*760c253cSXin Li            raise RuntimeError(
344*760c253cSXin Li                f"GitHub response was not OK: {f.status} {f.reason}"
345*760c253cSXin Li            )
346*760c253cSXin Li        response = f.read().decode("utf-8")
347*760c253cSXin Li    return json.loads(response)
348*760c253cSXin Li
349*760c253cSXin Li
350*760c253cSXin Liclass GitHubPRContext:
351*760c253cSXin Li    """Metadata and pathing context for a GitHub pull request checkout."""
352*760c253cSXin Li
353*760c253cSXin Li    def __init__(
354*760c253cSXin Li        self,
355*760c253cSXin Li        response: Dict[str, Any],
356*760c253cSXin Li        llvm_project_dir: Path,
357*760c253cSXin Li    ) -> None:
358*760c253cSXin Li        """Create a GitHubPRContext from a GitHub pulls api call.
359*760c253cSXin Li
360*760c253cSXin Li        Args:
361*760c253cSXin Li            response: A dictionary formed from the JSON sent by
362*760c253cSXin Li                the github pulls API endpoint.
363*760c253cSXin Li            llvm_project_dir: Path to llvm-project git directory.
364*760c253cSXin Li        """
365*760c253cSXin Li        try:
366*760c253cSXin Li            self.clone_url = response["head"]["repo"]["clone_url"]
367*760c253cSXin Li            self._title = response["title"]
368*760c253cSXin Li            self.body = response["body"]
369*760c253cSXin Li            self.base_ref = response["base"]["sha"]
370*760c253cSXin Li            self.head_ref = response["head"]["sha"]
371*760c253cSXin Li            self.llvm_project_dir = llvm_project_dir
372*760c253cSXin Li            self.number = int(response["number"])
373*760c253cSXin Li            self._fetched = False
374*760c253cSXin Li        except (ValueError, KeyError):
375*760c253cSXin Li            logging.error("Failed to parse GitHub response:\n%s", response)
376*760c253cSXin Li            raise
377*760c253cSXin Li
378*760c253cSXin Li    @property
379*760c253cSXin Li    def full_title(self) -> str:
380*760c253cSXin Li        return f"[PR{self.number}] {self._title}"
381*760c253cSXin Li
382*760c253cSXin Li    @property
383*760c253cSXin Li    def full_title_cleaned(self) -> str:
384*760c253cSXin Li        return re.sub(r"\W", "-", self.full_title)
385*760c253cSXin Li
386*760c253cSXin Li    def git_squash_chain_patch(self) -> Tuple[str, Set[Path]]:
387*760c253cSXin Li        """Replicate a squashed merge commit as a patch file.
388*760c253cSXin Li
389*760c253cSXin Li        Args:
390*760c253cSXin Li            git_root_dir: Root directory for a given local git repository
391*760c253cSXin Li                which contains the base_ref.
392*760c253cSXin Li            output: File path to write the patch to.
393*760c253cSXin Li
394*760c253cSXin Li        Returns:
395*760c253cSXin Li            The patch file contents.
396*760c253cSXin Li        """
397*760c253cSXin Li        self._fetch()
398*760c253cSXin Li        idx = random.randint(0, 2**32)
399*760c253cSXin Li        tmpbranch_name = f"squash-branch-{idx}"
400*760c253cSXin Li
401*760c253cSXin Li        with tempfile.TemporaryDirectory() as dir_str:
402*760c253cSXin Li            worktree_parent_dir = Path(dir_str)
403*760c253cSXin Li            commit_message_file = worktree_parent_dir / "commit_message"
404*760c253cSXin Li            # Need this separate from the commit message, otherwise the
405*760c253cSXin Li            # dir will be non-empty.
406*760c253cSXin Li            worktree_dir = worktree_parent_dir / "worktree"
407*760c253cSXin Li            with commit_message_file.open("w", encoding="utf-8") as f:
408*760c253cSXin Li                f.write(self.full_title)
409*760c253cSXin Li                f.write("\n\n")
410*760c253cSXin Li                f.write(
411*760c253cSXin Li                    "\n".join(
412*760c253cSXin Li                        textwrap.wrap(
413*760c253cSXin Li                            self.body, width=72, replace_whitespace=False
414*760c253cSXin Li                        )
415*760c253cSXin Li                    )
416*760c253cSXin Li                )
417*760c253cSXin Li                f.write("\n")
418*760c253cSXin Li
419*760c253cSXin Li            logging.debug("Base ref: %s", self.base_ref)
420*760c253cSXin Li            logging.debug("Head ref: %s", self.head_ref)
421*760c253cSXin Li            logging.debug(
422*760c253cSXin Li                "Creating worktree at '%s' with branch '%s'",
423*760c253cSXin Li                worktree_dir,
424*760c253cSXin Li                tmpbranch_name,
425*760c253cSXin Li            )
426*760c253cSXin Li            self._run(
427*760c253cSXin Li                [
428*760c253cSXin Li                    "git",
429*760c253cSXin Li                    "worktree",
430*760c253cSXin Li                    "add",
431*760c253cSXin Li                    "-b",
432*760c253cSXin Li                    tmpbranch_name,
433*760c253cSXin Li                    worktree_dir,
434*760c253cSXin Li                    self.base_ref,
435*760c253cSXin Li                ],
436*760c253cSXin Li                self.llvm_project_dir,
437*760c253cSXin Li            )
438*760c253cSXin Li            try:
439*760c253cSXin Li                self._run(
440*760c253cSXin Li                    ["git", "merge", "--squash", self.head_ref], worktree_dir
441*760c253cSXin Li                )
442*760c253cSXin Li                self._run(
443*760c253cSXin Li                    [
444*760c253cSXin Li                        "git",
445*760c253cSXin Li                        "commit",
446*760c253cSXin Li                        "-a",
447*760c253cSXin Li                        "-F",
448*760c253cSXin Li                        commit_message_file,
449*760c253cSXin Li                    ],
450*760c253cSXin Li                    worktree_dir,
451*760c253cSXin Li                )
452*760c253cSXin Li                changed_packages = get_changed_packages(
453*760c253cSXin Li                    worktree_dir, (self.base_ref, "HEAD")
454*760c253cSXin Li                )
455*760c253cSXin Li                patch_contents = git_format_patch(worktree_dir, "HEAD")
456*760c253cSXin Li            finally:
457*760c253cSXin Li                logging.debug(
458*760c253cSXin Li                    "Cleaning up worktree and deleting branch %s",
459*760c253cSXin Li                    tmpbranch_name,
460*760c253cSXin Li                )
461*760c253cSXin Li                self._run(
462*760c253cSXin Li                    ["git", "worktree", "remove", worktree_dir],
463*760c253cSXin Li                    self.llvm_project_dir,
464*760c253cSXin Li                )
465*760c253cSXin Li                self._run(
466*760c253cSXin Li                    ["git", "branch", "-D", tmpbranch_name],
467*760c253cSXin Li                    self.llvm_project_dir,
468*760c253cSXin Li                )
469*760c253cSXin Li        return (patch_contents, changed_packages)
470*760c253cSXin Li
471*760c253cSXin Li    def _fetch(self) -> None:
472*760c253cSXin Li        if not self._fetched:
473*760c253cSXin Li            logging.debug(
474*760c253cSXin Li                "Fetching from %s and setting FETCH_HEAD to %s",
475*760c253cSXin Li                self.clone_url,
476*760c253cSXin Li                self.head_ref,
477*760c253cSXin Li            )
478*760c253cSXin Li            self._run(
479*760c253cSXin Li                ["git", "fetch", self.clone_url, self.head_ref],
480*760c253cSXin Li                cwd=self.llvm_project_dir,
481*760c253cSXin Li            )
482*760c253cSXin Li            self._fetched = True
483*760c253cSXin Li
484*760c253cSXin Li    @staticmethod
485*760c253cSXin Li    def _run(
486*760c253cSXin Li        cmd: List[Union[str, Path]],
487*760c253cSXin Li        cwd: Path,
488*760c253cSXin Li        stdin: int = subprocess.DEVNULL,
489*760c253cSXin Li    ) -> subprocess.CompletedProcess:
490*760c253cSXin Li        """Helper for subprocess.run."""
491*760c253cSXin Li        return subprocess.run(
492*760c253cSXin Li            cmd,
493*760c253cSXin Li            cwd=cwd,
494*760c253cSXin Li            stdin=stdin,
495*760c253cSXin Li            stdout=subprocess.PIPE,
496*760c253cSXin Li            encoding="utf-8",
497*760c253cSXin Li            check=True,
498*760c253cSXin Li        )
499*760c253cSXin Li
500*760c253cSXin Li
501*760c253cSXin Lidef get_changed_packages(
502*760c253cSXin Li    llvm_project_dir: Path, ref: Union[str, Tuple[str, str]]
503*760c253cSXin Li) -> Set[Path]:
504*760c253cSXin Li    """Returns package paths which changed over a given ref.
505*760c253cSXin Li
506*760c253cSXin Li    Args:
507*760c253cSXin Li        llvm_project_dir: Path to llvm-project
508*760c253cSXin Li        ref: Git ref to check diff of. If set to a tuple, compares the diff
509*760c253cSXin Li            between the first and second ref.
510*760c253cSXin Li
511*760c253cSXin Li    Returns:
512*760c253cSXin Li        A set of package paths which were changed.
513*760c253cSXin Li    """
514*760c253cSXin Li    if isinstance(ref, tuple):
515*760c253cSXin Li        ref_from, ref_to = ref
516*760c253cSXin Li    elif isinstance(ref, str):
517*760c253cSXin Li        ref_from = ref + "^"
518*760c253cSXin Li        ref_to = ref
519*760c253cSXin Li    else:
520*760c253cSXin Li        raise TypeError(f"ref was {type(ref)}; need a tuple or a string")
521*760c253cSXin Li
522*760c253cSXin Li    logging.debug("Getting git diff between %s..%s", ref_from, ref_to)
523*760c253cSXin Li    proc = subprocess.run(
524*760c253cSXin Li        ["git", "diff", "--name-only", f"{ref_from}..{ref_to}"],
525*760c253cSXin Li        check=True,
526*760c253cSXin Li        encoding="utf-8",
527*760c253cSXin Li        stdout=subprocess.PIPE,
528*760c253cSXin Li        cwd=llvm_project_dir,
529*760c253cSXin Li    )
530*760c253cSXin Li    changed_paths = proc.stdout.splitlines()
531*760c253cSXin Li    logging.debug("Found %d changed files", len(changed_paths))
532*760c253cSXin Li    # Some LLVM projects are built by LLVM ebuild on x86, so always apply the
533*760c253cSXin Li    # patch to LLVM ebuild
534*760c253cSXin Li    packages = {LLVM_PKG_PATH}
535*760c253cSXin Li    for changed_path in changed_paths:
536*760c253cSXin Li        if changed_path.startswith("compiler-rt"):
537*760c253cSXin Li            packages.add(COMPILER_RT_PKG_PATH)
538*760c253cSXin Li            if "scudo" in changed_path:
539*760c253cSXin Li                packages.add(SCUDO_PKG_PATH)
540*760c253cSXin Li        elif changed_path.startswith("libunwind"):
541*760c253cSXin Li            packages.add(LIBUNWIND_PKG_PATH)
542*760c253cSXin Li        elif changed_path.startswith("libcxx") or changed_path.startswith(
543*760c253cSXin Li            "libcxxabi"
544*760c253cSXin Li        ):
545*760c253cSXin Li            packages.add(LIBCXX_PKG_PATH)
546*760c253cSXin Li        elif changed_path.startswith("lldb"):
547*760c253cSXin Li            packages.add(LLDB_PKG_PATH)
548*760c253cSXin Li    return packages
549*760c253cSXin Li
550*760c253cSXin Li
551*760c253cSXin Lidef _has_repo_child(path: Path) -> bool:
552*760c253cSXin Li    """Check if a given directory has a repo child.
553*760c253cSXin Li
554*760c253cSXin Li    Useful for checking if a directory has a chromiumos source tree.
555*760c253cSXin Li    """
556*760c253cSXin Li    child_maybe = path / ".repo"
557*760c253cSXin Li    return path.is_dir() and child_maybe.is_dir()
558*760c253cSXin Li
559*760c253cSXin Li
560*760c253cSXin Lidef _autodetect_chromiumos_root(
561*760c253cSXin Li    parent: Optional[Path] = None,
562*760c253cSXin Li) -> Optional[Path]:
563*760c253cSXin Li    """Find the root of the chromiumos source tree from the current workdir.
564*760c253cSXin Li
565*760c253cSXin Li    Returns:
566*760c253cSXin Li        The root directory of the current chromiumos source tree.
567*760c253cSXin Li        If the current working directory is not within a chromiumos source
568*760c253cSXin Li        tree, then this returns None.
569*760c253cSXin Li    """
570*760c253cSXin Li    if parent is None:
571*760c253cSXin Li        parent = Path.cwd()
572*760c253cSXin Li    if parent.resolve() == Path.root:
573*760c253cSXin Li        return None
574*760c253cSXin Li    if _has_repo_child(parent):
575*760c253cSXin Li        return parent
576*760c253cSXin Li    return _autodetect_chromiumos_root(parent.parent)
577*760c253cSXin Li
578*760c253cSXin Li
579*760c253cSXin Lidef _write_patch(title: str, contents: str, path: Path) -> None:
580*760c253cSXin Li    """Actually write the patch contents to a file."""
581*760c253cSXin Li    # This is mostly separated for mocking.
582*760c253cSXin Li    logging.info("Writing patch '%s' to '%s'", title, path)
583*760c253cSXin Li    path.write_text(contents, encoding="utf-8")
584*760c253cSXin Li
585*760c253cSXin Li
586*760c253cSXin Lidef validate_patch_args(
587*760c253cSXin Li    positional_args: List[str],
588*760c253cSXin Li) -> List[Union[LLVMGitRef, LLVMPullRequest]]:
589*760c253cSXin Li    """Checks that each ref_or_pr_num is in a valid format."""
590*760c253cSXin Li    patch_sources = []
591*760c253cSXin Li    for arg in positional_args:
592*760c253cSXin Li        patch_source: Union[LLVMGitRef, LLVMPullRequest]
593*760c253cSXin Li        if arg.startswith("p:"):
594*760c253cSXin Li            try:
595*760c253cSXin Li                pull_request_num = int(arg.lstrip("p:"))
596*760c253cSXin Li            except ValueError as e:
597*760c253cSXin Li                raise ValueError(
598*760c253cSXin Li                    f"GitHub Pull Request '{arg}' was not in the format of"
599*760c253cSXin Li                    f" 'p:NNNN': {e}"
600*760c253cSXin Li                )
601*760c253cSXin Li            logging.info("Patching remote GitHub PR '%s'", pull_request_num)
602*760c253cSXin Li            patch_source = LLVMPullRequest(pull_request_num)
603*760c253cSXin Li        else:
604*760c253cSXin Li            logging.info("Patching local ref '%s'", arg)
605*760c253cSXin Li            patch_source = LLVMGitRef(arg)
606*760c253cSXin Li        patch_sources.append(patch_source)
607*760c253cSXin Li    return patch_sources
608*760c253cSXin Li
609*760c253cSXin Li
610*760c253cSXin Lidef parse_args() -> argparse.Namespace:
611*760c253cSXin Li    """Parse CLI arguments for this script."""
612*760c253cSXin Li
613*760c253cSXin Li    parser = argparse.ArgumentParser(
614*760c253cSXin Li        "get_patch",
615*760c253cSXin Li        description=__doc__,
616*760c253cSXin Li        formatter_class=argparse.RawDescriptionHelpFormatter,
617*760c253cSXin Li    )
618*760c253cSXin Li    parser.add_argument(
619*760c253cSXin Li        "-c",
620*760c253cSXin Li        "--chromiumos-root",
621*760c253cSXin Li        help="""Path to the chromiumos source tree root.
622*760c253cSXin Li        Tries to autodetect if not passed.
623*760c253cSXin Li        """,
624*760c253cSXin Li    )
625*760c253cSXin Li    parser.add_argument(
626*760c253cSXin Li        "-l",
627*760c253cSXin Li        "--llvm",
628*760c253cSXin Li        help="""Path to the llvm dir.
629*760c253cSXin Li        Tries to autodetect from chromiumos root if not passed.
630*760c253cSXin Li        """,
631*760c253cSXin Li    )
632*760c253cSXin Li    parser.add_argument(
633*760c253cSXin Li        "-s",
634*760c253cSXin Li        "--start-ref",
635*760c253cSXin Li        default="HEAD",
636*760c253cSXin Li        help="""The starting ref for which to apply patches.
637*760c253cSXin Li        """,
638*760c253cSXin Li    )
639*760c253cSXin Li    parser.add_argument(
640*760c253cSXin Li        "-p",
641*760c253cSXin Li        "--platform",
642*760c253cSXin Li        action="append",
643*760c253cSXin Li        help="""Apply this patch to the give platform. Common options include
644*760c253cSXin Li        'chromiumos' and 'android'. Can be specified multiple times to
645*760c253cSXin Li        apply to multiple platforms. If not passed, platform is set to
646*760c253cSXin Li        'chromiumos'.
647*760c253cSXin Li        """,
648*760c253cSXin Li    )
649*760c253cSXin Li    parser.add_argument(
650*760c253cSXin Li        "--dry-run",
651*760c253cSXin Li        action="store_true",
652*760c253cSXin Li        help="Run normally, but don't make any changes. Read-only mode.",
653*760c253cSXin Li    )
654*760c253cSXin Li    parser.add_argument(
655*760c253cSXin Li        "-v",
656*760c253cSXin Li        "--verbose",
657*760c253cSXin Li        action="store_true",
658*760c253cSXin Li        help="Enable verbose logging.",
659*760c253cSXin Li    )
660*760c253cSXin Li    parser.add_argument(
661*760c253cSXin Li        "ref_or_pr_num",
662*760c253cSXin Li        nargs="+",
663*760c253cSXin Li        help="""Git ref or GitHub PR number to make patches.
664*760c253cSXin Li        To patch a GitHub PR, use the syntax p:NNNN (e.g. 'p:123456').
665*760c253cSXin Li        """,
666*760c253cSXin Li        type=str,
667*760c253cSXin Li    )
668*760c253cSXin Li    args = parser.parse_args()
669*760c253cSXin Li
670*760c253cSXin Li    logging.basicConfig(
671*760c253cSXin Li        format=">> %(asctime)s: %(levelname)s: %(filename)s:%(lineno)d: "
672*760c253cSXin Li        "%(message)s",
673*760c253cSXin Li        level=logging.DEBUG if args.verbose else logging.INFO,
674*760c253cSXin Li    )
675*760c253cSXin Li
676*760c253cSXin Li    args.patch_sources = validate_patch_args(args.ref_or_pr_num)
677*760c253cSXin Li    if args.chromiumos_root:
678*760c253cSXin Li        if not _has_repo_child(args.chromiumos_root):
679*760c253cSXin Li            parser.error("chromiumos root directly passed but has no .repo")
680*760c253cSXin Li        logging.debug("chromiumos root directly passed; found and verified")
681*760c253cSXin Li    elif tmp := _autodetect_chromiumos_root():
682*760c253cSXin Li        logging.debug("chromiumos root autodetected; found and verified")
683*760c253cSXin Li        args.chromiumos_root = tmp
684*760c253cSXin Li    else:
685*760c253cSXin Li        parser.error(
686*760c253cSXin Li            "Could not autodetect chromiumos root. Use '-c' to pass the "
687*760c253cSXin Li            "chromiumos root path directly."
688*760c253cSXin Li        )
689*760c253cSXin Li
690*760c253cSXin Li    if not args.llvm:
691*760c253cSXin Li        if (args.chromiumos_root / LLVM_PROJECT_PATH).is_dir():
692*760c253cSXin Li            args.llvm = args.chromiumos_root / LLVM_PROJECT_PATH
693*760c253cSXin Li        else:
694*760c253cSXin Li            parser.error(
695*760c253cSXin Li                "Could not autodetect llvm-project dir. Use '-l' to pass the "
696*760c253cSXin Li                "llvm-project directly"
697*760c253cSXin Li            )
698*760c253cSXin Li    return args
699*760c253cSXin Li
700*760c253cSXin Li
701*760c253cSXin Lidef main() -> None:
702*760c253cSXin Li    """Entry point for the program."""
703*760c253cSXin Li
704*760c253cSXin Li    args = parse_args()
705*760c253cSXin Li
706*760c253cSXin Li    # For the vast majority of cases, we'll only want to set platform to
707*760c253cSXin Li    # ["chromiumos"], so let's make that the default.
708*760c253cSXin Li    platforms: List[str] = args.platform if args.platform else ["chromiumos"]
709*760c253cSXin Li
710*760c253cSXin Li    ctx = PatchContext(
711*760c253cSXin Li        chromiumos_root=args.chromiumos_root,
712*760c253cSXin Li        llvm_project_dir=args.llvm,
713*760c253cSXin Li        start_ref=LLVMGitRef(args.start_ref),
714*760c253cSXin Li        platforms=platforms,
715*760c253cSXin Li        dry_run=args.dry_run,
716*760c253cSXin Li    )
717*760c253cSXin Li    for patch_source in args.patch_sources:
718*760c253cSXin Li        ctx.apply_patches(patch_source)
719*760c253cSXin Li
720*760c253cSXin Li
721*760c253cSXin Liif __name__ == "__main__":
722*760c253cSXin Li    main()
723