xref: /aosp_15_r20/external/executorch/build/pick_doc_commits.py (revision 523fa7a60841cd1ecfb9cc4201f1ca8b03ed023a)
1*523fa7a6SAndroid Build Coastguard Worker#!/usr/bin/env python3
2*523fa7a6SAndroid Build Coastguard Worker# Copyright (c) Meta Platforms, Inc. and affiliates.
3*523fa7a6SAndroid Build Coastguard Worker# All rights reserved.
4*523fa7a6SAndroid Build Coastguard Worker#
5*523fa7a6SAndroid Build Coastguard Worker# This source code is licensed under the BSD-style license found in the
6*523fa7a6SAndroid Build Coastguard Worker# LICENSE file in the root directory of this source tree.
7*523fa7a6SAndroid Build Coastguard Worker
8*523fa7a6SAndroid Build Coastguard Worker"""Helps find commits to cherrypick into a release branch.
9*523fa7a6SAndroid Build Coastguard Worker
10*523fa7a6SAndroid Build Coastguard WorkerUsage:
11*523fa7a6SAndroid Build Coastguard Worker  pick_doc_commits.py --main=origin/main --release=origin/release/5.5
12*523fa7a6SAndroid Build Coastguard Worker
13*523fa7a6SAndroid Build Coastguard WorkerIt will find commits on the main branch that are not on the release branch, and
14*523fa7a6SAndroid Build Coastguard Workerfilter them down to the docs-only commits that should be cherrypicked. It will
15*523fa7a6SAndroid Build Coastguard Workeralso print the commits that were filtered out.
16*523fa7a6SAndroid Build Coastguard Worker
17*523fa7a6SAndroid Build Coastguard WorkerThis tool will not actually modify the git repo, it will only print the commands
18*523fa7a6SAndroid Build Coastguard Workerto run.
19*523fa7a6SAndroid Build Coastguard Worker
20*523fa7a6SAndroid Build Coastguard WorkerMust be run from inside the repo, ideally after a recent `git pull`. Does not
21*523fa7a6SAndroid Build Coastguard Workercare which branch is currently checked out.
22*523fa7a6SAndroid Build Coastguard Worker"""
23*523fa7a6SAndroid Build Coastguard Worker
24*523fa7a6SAndroid Build Coastguard Workerimport argparse
25*523fa7a6SAndroid Build Coastguard Workerimport datetime
26*523fa7a6SAndroid Build Coastguard Workerimport re
27*523fa7a6SAndroid Build Coastguard Workerimport subprocess
28*523fa7a6SAndroid Build Coastguard Workerimport sys
29*523fa7a6SAndroid Build Coastguard Workerimport textwrap
30*523fa7a6SAndroid Build Coastguard Workerfrom typing import List
31*523fa7a6SAndroid Build Coastguard Worker
32*523fa7a6SAndroid Build Coastguard Worker
33*523fa7a6SAndroid Build Coastguard Worker# The script will print extra info when this is > 0, and more at higher levels.
34*523fa7a6SAndroid Build Coastguard Worker# Controlled by the --verbose flag.
35*523fa7a6SAndroid Build Coastguard Workerverbosity = 0
36*523fa7a6SAndroid Build Coastguard Worker
37*523fa7a6SAndroid Build Coastguard Worker
38*523fa7a6SAndroid Build Coastguard Workerdef debug_log(message: str):
39*523fa7a6SAndroid Build Coastguard Worker    """Prints a message to stderr if verbosity is greater than zero."""
40*523fa7a6SAndroid Build Coastguard Worker    global verbosity
41*523fa7a6SAndroid Build Coastguard Worker    if verbosity > 0:
42*523fa7a6SAndroid Build Coastguard Worker        sys.stderr.write(f"VERBOSE: {message}\n")
43*523fa7a6SAndroid Build Coastguard Worker
44*523fa7a6SAndroid Build Coastguard Worker
45*523fa7a6SAndroid Build Coastguard Workerdef run_git(command: List[str]) -> List[str]:
46*523fa7a6SAndroid Build Coastguard Worker    """Runs a git command and returns its stdout as a list of lines.
47*523fa7a6SAndroid Build Coastguard Worker
48*523fa7a6SAndroid Build Coastguard Worker    Prints the command and its output to debug_log() if verbosity is greater
49*523fa7a6SAndroid Build Coastguard Worker    than 1.
50*523fa7a6SAndroid Build Coastguard Worker
51*523fa7a6SAndroid Build Coastguard Worker    Args:
52*523fa7a6SAndroid Build Coastguard Worker        command: The args to pass to `git`, without the leading `git` itself.
53*523fa7a6SAndroid Build Coastguard Worker    Returns:
54*523fa7a6SAndroid Build Coastguard Worker        A list of the non-empty lines printed to stdout, without trailing
55*523fa7a6SAndroid Build Coastguard Worker        newlines.
56*523fa7a6SAndroid Build Coastguard Worker    Raises:
57*523fa7a6SAndroid Build Coastguard Worker        Exception: The command failed.
58*523fa7a6SAndroid Build Coastguard Worker    """
59*523fa7a6SAndroid Build Coastguard Worker    try:
60*523fa7a6SAndroid Build Coastguard Worker        if verbosity > 1:  # Higher verbosity required
61*523fa7a6SAndroid Build Coastguard Worker            debug_log("Running command: 'git " + " ".join(command) + "'")
62*523fa7a6SAndroid Build Coastguard Worker        result = subprocess.run(["git", *command], capture_output=True, text=True)
63*523fa7a6SAndroid Build Coastguard Worker        if result.returncode != 0:
64*523fa7a6SAndroid Build Coastguard Worker            raise Exception(f"Error running command '{command}':\n{result.stderr}")
65*523fa7a6SAndroid Build Coastguard Worker        lines = result.stdout.split("\n")
66*523fa7a6SAndroid Build Coastguard Worker        # Remove empty and whitespace-only lines.
67*523fa7a6SAndroid Build Coastguard Worker        lines = [line.strip() for line in lines if line.strip()]
68*523fa7a6SAndroid Build Coastguard Worker        global verbose
69*523fa7a6SAndroid Build Coastguard Worker        if verbosity > 1:
70*523fa7a6SAndroid Build Coastguard Worker            debug_log("-----BEGIN GIT OUTPUT-----")
71*523fa7a6SAndroid Build Coastguard Worker            for line in lines:
72*523fa7a6SAndroid Build Coastguard Worker                debug_log(line)
73*523fa7a6SAndroid Build Coastguard Worker            debug_log("-----END GIT OUTPUT-----")
74*523fa7a6SAndroid Build Coastguard Worker        return lines
75*523fa7a6SAndroid Build Coastguard Worker    except Exception as e:
76*523fa7a6SAndroid Build Coastguard Worker        raise Exception(f"Error running command '{command}': {e}")
77*523fa7a6SAndroid Build Coastguard Worker
78*523fa7a6SAndroid Build Coastguard Worker
79*523fa7a6SAndroid Build Coastguard Workerclass Commit:
80*523fa7a6SAndroid Build Coastguard Worker    """A git commit hash and its one-line message."""
81*523fa7a6SAndroid Build Coastguard Worker
82*523fa7a6SAndroid Build Coastguard Worker    def __init__(self, hash: str, message: str = ""):
83*523fa7a6SAndroid Build Coastguard Worker        """Creates a new Commit with the given hash.
84*523fa7a6SAndroid Build Coastguard Worker
85*523fa7a6SAndroid Build Coastguard Worker        Args:
86*523fa7a6SAndroid Build Coastguard Worker            hash: The hexadecimal hash of the commit.
87*523fa7a6SAndroid Build Coastguard Worker            message: The one-line summary of the commit. If empty, this method
88*523fa7a6SAndroid Build Coastguard Worker                will ask git for the commit message.
89*523fa7a6SAndroid Build Coastguard Worker        """
90*523fa7a6SAndroid Build Coastguard Worker        self.hash = hash.strip()
91*523fa7a6SAndroid Build Coastguard Worker        if not message:
92*523fa7a6SAndroid Build Coastguard Worker            # Ask git for the commit message.
93*523fa7a6SAndroid Build Coastguard Worker            lines = run_git(["log", "-1", "--pretty=%s", self.hash])
94*523fa7a6SAndroid Build Coastguard Worker            # Should just be one line, but could be zero.
95*523fa7a6SAndroid Build Coastguard Worker            message = " ".join(lines)
96*523fa7a6SAndroid Build Coastguard Worker        self.message = message.strip()
97*523fa7a6SAndroid Build Coastguard Worker
98*523fa7a6SAndroid Build Coastguard Worker    @staticmethod
99*523fa7a6SAndroid Build Coastguard Worker    def from_line(line: str) -> "Commit":
100*523fa7a6SAndroid Build Coastguard Worker        """Creates a Commit from a string of the form '<hash> [<message>]'."""
101*523fa7a6SAndroid Build Coastguard Worker        parts = line.split(" ", maxsplit=1)
102*523fa7a6SAndroid Build Coastguard Worker        parts = [part.strip() for part in parts if part.strip()]
103*523fa7a6SAndroid Build Coastguard Worker        assert len(parts) >= 1, f"Expected at least one part in line '{line}'"
104*523fa7a6SAndroid Build Coastguard Worker        return Commit(hash=parts[0], message=parts[1] if len(parts) > 1 else "")
105*523fa7a6SAndroid Build Coastguard Worker
106*523fa7a6SAndroid Build Coastguard Worker    def __repr__(self):
107*523fa7a6SAndroid Build Coastguard Worker        return f"Commit('{self.hash[:8]}', '{self.message}')"
108*523fa7a6SAndroid Build Coastguard Worker
109*523fa7a6SAndroid Build Coastguard Worker    def __str__(self):
110*523fa7a6SAndroid Build Coastguard Worker        return f"{self.hash[:8]} {self.message}"
111*523fa7a6SAndroid Build Coastguard Worker
112*523fa7a6SAndroid Build Coastguard Worker
113*523fa7a6SAndroid Build Coastguard Workerdef is_doc_only_commit(commit: Commit) -> bool:
114*523fa7a6SAndroid Build Coastguard Worker    """Returns True if the commit only touched "documentation files"."""
115*523fa7a6SAndroid Build Coastguard Worker
116*523fa7a6SAndroid Build Coastguard Worker    def is_doc_file(path: str) -> bool:
117*523fa7a6SAndroid Build Coastguard Worker        """Returns true if the path is considered to be a "documentation file"."""
118*523fa7a6SAndroid Build Coastguard Worker        return (
119*523fa7a6SAndroid Build Coastguard Worker            # Everything under docs, regardless of the file type.
120*523fa7a6SAndroid Build Coastguard Worker            path.startswith("docs/")
121*523fa7a6SAndroid Build Coastguard Worker            # Any markdown or RST file in the repo.
122*523fa7a6SAndroid Build Coastguard Worker            or path.endswith(".md")
123*523fa7a6SAndroid Build Coastguard Worker            or path.endswith(".rst")
124*523fa7a6SAndroid Build Coastguard Worker        )
125*523fa7a6SAndroid Build Coastguard Worker
126*523fa7a6SAndroid Build Coastguard Worker    # The first line is the full hash, and the rest are the files modified by
127*523fa7a6SAndroid Build Coastguard Worker    # the commit, relative to the root of the repo.
128*523fa7a6SAndroid Build Coastguard Worker    lines = run_git(["diff-tree", "--name-only", "-r", commit.hash])
129*523fa7a6SAndroid Build Coastguard Worker    all_files = frozenset(lines[1:])
130*523fa7a6SAndroid Build Coastguard Worker    doc_files = frozenset(filter(is_doc_file, all_files))
131*523fa7a6SAndroid Build Coastguard Worker    non_doc_files = all_files - doc_files
132*523fa7a6SAndroid Build Coastguard Worker    is_doc_only = all_files == doc_files
133*523fa7a6SAndroid Build Coastguard Worker
134*523fa7a6SAndroid Build Coastguard Worker    if verbosity > 0 and not is_doc_only:
135*523fa7a6SAndroid Build Coastguard Worker        debug_log(
136*523fa7a6SAndroid Build Coastguard Worker            f"{repr(commit)} touches {len(non_doc_files)} non-doc files, "
137*523fa7a6SAndroid Build Coastguard Worker            + f"like '{sorted(non_doc_files)[0]}'."
138*523fa7a6SAndroid Build Coastguard Worker        )
139*523fa7a6SAndroid Build Coastguard Worker
140*523fa7a6SAndroid Build Coastguard Worker    return is_doc_only
141*523fa7a6SAndroid Build Coastguard Worker
142*523fa7a6SAndroid Build Coastguard Worker
143*523fa7a6SAndroid Build Coastguard Workerdef print_wrapped(text: str, width: int = 80) -> None:
144*523fa7a6SAndroid Build Coastguard Worker    """Print text wrapped to fit within the given width.
145*523fa7a6SAndroid Build Coastguard Worker
146*523fa7a6SAndroid Build Coastguard Worker    Indents additional lines by four spaces.
147*523fa7a6SAndroid Build Coastguard Worker    """
148*523fa7a6SAndroid Build Coastguard Worker    print("\n    ".join(textwrap.wrap(text, width=width - 4, break_on_hyphens=False)))
149*523fa7a6SAndroid Build Coastguard Worker
150*523fa7a6SAndroid Build Coastguard Worker
151*523fa7a6SAndroid Build Coastguard Workerdef parse_args() -> argparse.Namespace:
152*523fa7a6SAndroid Build Coastguard Worker    parser = argparse.ArgumentParser(
153*523fa7a6SAndroid Build Coastguard Worker        description="Prints differences between git branches."
154*523fa7a6SAndroid Build Coastguard Worker    )
155*523fa7a6SAndroid Build Coastguard Worker    parser.add_argument(
156*523fa7a6SAndroid Build Coastguard Worker        "--main",
157*523fa7a6SAndroid Build Coastguard Worker        default="origin/main",
158*523fa7a6SAndroid Build Coastguard Worker        type=str,
159*523fa7a6SAndroid Build Coastguard Worker        help="The name of the main (source) branch to pick commits from.",
160*523fa7a6SAndroid Build Coastguard Worker    )
161*523fa7a6SAndroid Build Coastguard Worker    parser.add_argument(
162*523fa7a6SAndroid Build Coastguard Worker        "--release",
163*523fa7a6SAndroid Build Coastguard Worker        type=str,
164*523fa7a6SAndroid Build Coastguard Worker        help="The name of the release (destination) branch to pick commits onto, "
165*523fa7a6SAndroid Build Coastguard Worker        + "ideally with the 'origin/' prefix",
166*523fa7a6SAndroid Build Coastguard Worker    )
167*523fa7a6SAndroid Build Coastguard Worker    parser.add_argument(
168*523fa7a6SAndroid Build Coastguard Worker        "-v",
169*523fa7a6SAndroid Build Coastguard Worker        "--verbose",
170*523fa7a6SAndroid Build Coastguard Worker        action="count",
171*523fa7a6SAndroid Build Coastguard Worker        default=0,
172*523fa7a6SAndroid Build Coastguard Worker        help="Log extra output. Specify more times (-vv) for more output.",
173*523fa7a6SAndroid Build Coastguard Worker    )
174*523fa7a6SAndroid Build Coastguard Worker    return parser.parse_args()
175*523fa7a6SAndroid Build Coastguard Worker
176*523fa7a6SAndroid Build Coastguard Worker
177*523fa7a6SAndroid Build Coastguard Workerdef main():
178*523fa7a6SAndroid Build Coastguard Worker    args = parse_args()
179*523fa7a6SAndroid Build Coastguard Worker    main_branch = args.main
180*523fa7a6SAndroid Build Coastguard Worker    release_branch = args.release
181*523fa7a6SAndroid Build Coastguard Worker
182*523fa7a6SAndroid Build Coastguard Worker    global verbosity
183*523fa7a6SAndroid Build Coastguard Worker    verbosity = args.verbose
184*523fa7a6SAndroid Build Coastguard Worker
185*523fa7a6SAndroid Build Coastguard Worker    # Returns a list of hashes that are on the main branch but not the release
186*523fa7a6SAndroid Build Coastguard Worker    # branch. Each hash is preceded by `+ ` if the commit has not been cherry
187*523fa7a6SAndroid Build Coastguard Worker    # picked onto the release branch, or `- ` if it has.
188*523fa7a6SAndroid Build Coastguard Worker    cherry_lines = run_git(["cherry", release_branch, main_branch])
189*523fa7a6SAndroid Build Coastguard Worker    print_wrapped(
190*523fa7a6SAndroid Build Coastguard Worker        f"Commits on '{main_branch}' that have already been cherry-picked into '{release_branch}':"
191*523fa7a6SAndroid Build Coastguard Worker    )
192*523fa7a6SAndroid Build Coastguard Worker    if not cherry_lines:
193*523fa7a6SAndroid Build Coastguard Worker        print("- <none>")
194*523fa7a6SAndroid Build Coastguard Worker    candidate_commits = []
195*523fa7a6SAndroid Build Coastguard Worker    for line in cherry_lines:
196*523fa7a6SAndroid Build Coastguard Worker        commit = Commit.from_line(line[2:])
197*523fa7a6SAndroid Build Coastguard Worker        if line.startswith("+ "):
198*523fa7a6SAndroid Build Coastguard Worker            candidate_commits.append(commit)
199*523fa7a6SAndroid Build Coastguard Worker        elif line.startswith("- "):
200*523fa7a6SAndroid Build Coastguard Worker            print(f"- {commit}")
201*523fa7a6SAndroid Build Coastguard Worker    print("")
202*523fa7a6SAndroid Build Coastguard Worker
203*523fa7a6SAndroid Build Coastguard Worker    # Filter out and print the commits that touch non-documentation files.
204*523fa7a6SAndroid Build Coastguard Worker    print_wrapped(
205*523fa7a6SAndroid Build Coastguard Worker        f"Will not pick these commits on '{main_branch}' that touch non-documentation files:"
206*523fa7a6SAndroid Build Coastguard Worker    )
207*523fa7a6SAndroid Build Coastguard Worker    if not candidate_commits:
208*523fa7a6SAndroid Build Coastguard Worker        print("- <none>")
209*523fa7a6SAndroid Build Coastguard Worker    doc_only_commits = []
210*523fa7a6SAndroid Build Coastguard Worker    for commit in candidate_commits:
211*523fa7a6SAndroid Build Coastguard Worker        if is_doc_only_commit(commit):
212*523fa7a6SAndroid Build Coastguard Worker            doc_only_commits.append(commit)
213*523fa7a6SAndroid Build Coastguard Worker        else:
214*523fa7a6SAndroid Build Coastguard Worker            print(f"- {commit}")
215*523fa7a6SAndroid Build Coastguard Worker    print("")
216*523fa7a6SAndroid Build Coastguard Worker
217*523fa7a6SAndroid Build Coastguard Worker    # Print the commits to cherry-pick.
218*523fa7a6SAndroid Build Coastguard Worker    print_wrapped(
219*523fa7a6SAndroid Build Coastguard Worker        f"Remaining '{main_branch}' commits that touch only documentation files; "
220*523fa7a6SAndroid Build Coastguard Worker        + f"will be cherry-picked into '{release_branch}':"
221*523fa7a6SAndroid Build Coastguard Worker    )
222*523fa7a6SAndroid Build Coastguard Worker    if not doc_only_commits:
223*523fa7a6SAndroid Build Coastguard Worker        print("- <none>")
224*523fa7a6SAndroid Build Coastguard Worker    for commit in doc_only_commits:
225*523fa7a6SAndroid Build Coastguard Worker        print(f"- {commit}")
226*523fa7a6SAndroid Build Coastguard Worker    print("")
227*523fa7a6SAndroid Build Coastguard Worker
228*523fa7a6SAndroid Build Coastguard Worker    # Print instructions for cherry-picking the commits.
229*523fa7a6SAndroid Build Coastguard Worker    if doc_only_commits:
230*523fa7a6SAndroid Build Coastguard Worker        # Recommend a unique branch name.
231*523fa7a6SAndroid Build Coastguard Worker        suffix = datetime.datetime.utcnow().strftime("%Y%m%d%H%M")
232*523fa7a6SAndroid Build Coastguard Worker        branch_name = "cherrypick-" + release_branch.replace("/", "-") + "-" + suffix
233*523fa7a6SAndroid Build Coastguard Worker
234*523fa7a6SAndroid Build Coastguard Worker        print("Cherry pick by running the commands:")
235*523fa7a6SAndroid Build Coastguard Worker        print("```")
236*523fa7a6SAndroid Build Coastguard Worker        print(f"git checkout {release_branch}")
237*523fa7a6SAndroid Build Coastguard Worker        print(
238*523fa7a6SAndroid Build Coastguard Worker            # Split lines with backslashes to make long lists more legible but
239*523fa7a6SAndroid Build Coastguard Worker            # still copy-pasteable.
240*523fa7a6SAndroid Build Coastguard Worker            "git cherry-pick \\\n  "
241*523fa7a6SAndroid Build Coastguard Worker            + " \\\n  ".join([commit.hash for commit in doc_only_commits])
242*523fa7a6SAndroid Build Coastguard Worker        )
243*523fa7a6SAndroid Build Coastguard Worker        print(f"git checkout -b {branch_name}")
244*523fa7a6SAndroid Build Coastguard Worker        print("```")
245*523fa7a6SAndroid Build Coastguard Worker        print("")
246*523fa7a6SAndroid Build Coastguard Worker        print("To verify that this worked, re-run this script with the arguments:")
247*523fa7a6SAndroid Build Coastguard Worker        print("```")
248*523fa7a6SAndroid Build Coastguard Worker        print(f"--main={main_branch} --release={branch_name}")
249*523fa7a6SAndroid Build Coastguard Worker        print("```")
250*523fa7a6SAndroid Build Coastguard Worker        print("It should show no doc-only commits to cherry-pick.")
251*523fa7a6SAndroid Build Coastguard Worker        print("")
252*523fa7a6SAndroid Build Coastguard Worker        print(f"Then, push {branch_name} to GitHub:")
253*523fa7a6SAndroid Build Coastguard Worker        print("```")
254*523fa7a6SAndroid Build Coastguard Worker        print(f"git push --set-upstream origin {branch_name}")
255*523fa7a6SAndroid Build Coastguard Worker        print("```")
256*523fa7a6SAndroid Build Coastguard Worker        print("")
257*523fa7a6SAndroid Build Coastguard Worker        print_wrapped(
258*523fa7a6SAndroid Build Coastguard Worker            "When creating the PR, remember to set the 'into' branch to be "
259*523fa7a6SAndroid Build Coastguard Worker            # Remove "origin/" if present since it won't appear in the GitHub
260*523fa7a6SAndroid Build Coastguard Worker            # UI.
261*523fa7a6SAndroid Build Coastguard Worker            + f"'{re.sub('^origin/', '', release_branch)}'."
262*523fa7a6SAndroid Build Coastguard Worker        )
263*523fa7a6SAndroid Build Coastguard Worker    else:
264*523fa7a6SAndroid Build Coastguard Worker        print_wrapped(
265*523fa7a6SAndroid Build Coastguard Worker            "It looks like there are no doc-only commits "
266*523fa7a6SAndroid Build Coastguard Worker            + f"on '{main_branch}' to cherry-pick into '{release_branch}'."
267*523fa7a6SAndroid Build Coastguard Worker        )
268*523fa7a6SAndroid Build Coastguard Worker
269*523fa7a6SAndroid Build Coastguard Worker
270*523fa7a6SAndroid Build Coastguard Workerif __name__ == "__main__":
271*523fa7a6SAndroid Build Coastguard Worker    main()
272