xref: /aosp_15_r20/external/toolchain-utils/cros_utils/git_utils.py (revision 760c253c1ed00ce9abd48f8546f08516e57485fe)
1# Copyright 2024 The ChromiumOS Authors
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5"""Shared utilities for working with git."""
6
7import contextlib
8import logging
9from pathlib import Path
10import re
11import shlex
12import subprocess
13import tempfile
14from typing import Generator, Iterable, List
15
16
17# Email address used to tag the detective as a reviewer.
18REVIEWER_DETECTIVE = "[email protected]"
19
20
21def _parse_cls_from_upload_output(upload_output: str) -> List[int]:
22    """Returns the CL number in the given upload output."""
23    id_regex = re.compile(
24        r"^remote:\s+https://"
25        r"(?:chromium|chrome-internal)"
26        r"-review\S+/\+/(\d+)\s",
27        re.MULTILINE,
28    )
29
30    results = id_regex.findall(upload_output)
31    if not results:
32        raise ValueError(
33            f"Wanted at least one match for {id_regex} in {upload_output!r}; "
34            "found 0"
35        )
36    return [int(x) for x in results]
37
38
39def upload_to_gerrit(
40    git_repo: Path,
41    remote: str,
42    branch: str,
43    reviewers: Iterable[str] = (),
44    cc: Iterable[str] = (),
45    ref: str = "HEAD",
46) -> List[int]:
47    """Uploads `ref` to gerrit, optionally adding reviewers/CCs."""
48    # https://gerrit-review.googlesource.com/Documentation/user-upload.html#reviewers
49    # for more info on the `%` params.
50    option_list = [f"r={x}" for x in reviewers]
51    option_list += (f"cc={x}" for x in cc)
52    if option_list:
53        trailing_options = "%" + ",".join(option_list)
54    else:
55        trailing_options = ""
56
57    run_result = subprocess.run(
58        [
59            "git",
60            "push",
61            remote,
62            # https://gerrit-review.googlesource.com/Documentation/user-upload.html#reviewers
63            # for more info on the `%` params.
64            f"{ref}:refs/for/{branch}{trailing_options}",
65        ],
66        cwd=git_repo,
67        check=False,
68        stdin=subprocess.DEVNULL,
69        stdout=subprocess.PIPE,
70        stderr=subprocess.STDOUT,
71        encoding="utf-8",
72    )
73
74    logging.info(
75        "`git push`ing %s to %s/%s had this output:\n%s",
76        ref,
77        remote,
78        branch,
79        run_result.stdout,
80    )
81    run_result.check_returncode()
82    return _parse_cls_from_upload_output(run_result.stdout)
83
84
85def try_set_autosubmit_labels(cwd: Path, cl_id: int) -> None:
86    """Sets autosubmit on a CL. Logs - not raises - on failure.
87
88    This sets a series of convenience labels on the given cl_number, so landing
89    it (e.g., for the detective) is as easy as possible.
90
91    Args:
92        cwd: the directory that the `gerrit` tool should be run in. Anywhere in
93            a ChromeOS tree will do. The `gerrit` command fails if it isn't run
94            from within a ChromeOS tree.
95        cl_id: The CL number to apply labels to.
96    """
97    gerrit_cl_id = str(cl_id)
98    gerrit_commands = (
99        ["gerrit", "label-as", gerrit_cl_id, "1"],
100        ["gerrit", "label-cq", gerrit_cl_id, "1"],
101        ["gerrit", "label-v", gerrit_cl_id, "1"],
102    )
103    for cmd in gerrit_commands:
104        # Run the gerrit commands inside of toolchain_utils, since `gerrit`
105        # needs to be run inside of a ChromeOS tree to work. While
106        # `toolchain-utils` can be checked out on its own, that's not how this
107        # script is expeted to be used.
108        return_code = subprocess.run(
109            cmd,
110            cwd=cwd,
111            check=False,
112            stdin=subprocess.DEVNULL,
113        ).returncode
114        if return_code:
115            logging.warning(
116                "Failed to run gerrit command %s. Ignoring.",
117                shlex.join(cmd),
118            )
119
120
121@contextlib.contextmanager
122def create_worktree(git_directory: Path) -> Generator[Path, None, None]:
123    """Creates a temp worktree of `git_directory`, yielding the result."""
124    with tempfile.TemporaryDirectory(prefix="update_kernel_afdo_") as t:
125        tempdir = Path(t)
126        logging.info(
127            "Establishing worktree of %s in %s", git_directory, tempdir
128        )
129        subprocess.run(
130            [
131                "git",
132                "worktree",
133                "add",
134                "--detach",
135                "--force",
136                tempdir,
137            ],
138            cwd=git_directory,
139            check=True,
140            stdin=subprocess.DEVNULL,
141        )
142
143        try:
144            yield tempdir
145        finally:
146            # Explicitly `git worktree remove` here, so the parent worktree's
147            # metadata is cleaned up promptly.
148            subprocess.run(
149                [
150                    "git",
151                    "worktree",
152                    "remove",
153                    "--force",
154                    tempdir,
155                ],
156                cwd=git_directory,
157                check=False,
158                stdin=subprocess.DEVNULL,
159            )
160