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