#!/usr/bin/env python3 # Copyright 2021 The ChromiumOS Authors # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. """Release branch updater tool. This is a tool to merge from the main branch into a release branch. Inspired by the fingerprint release process: http://go/cros-fingerprint-firmware-branching-and-signing and now used by other boards. """ from __future__ import print_function import argparse import os import re import subprocess import sys import textwrap BUG_NONE_PATTERN = re.compile("none", flags=re.IGNORECASE) def git_commit_msg(cros_main, branch, head, merge_head, rel_paths, cmd): """Generates a merge commit message based off of relevant changes. This function obtains the relevant commits from the given relative paths in order to extract the bug numbers. It constructs the git commit message showing the command used to find the relevant commits. Args: cros_main: String indicating the origin branch name branch: String indicating the release branch name head: String indicating the HEAD refspec merge_head: String indicating the merge branch refspec. rel_paths: String containing all the relevant paths for this particular baseboard or board. cmd: String of the input command. Returns: A String containing the git commit message with the exception of the Signed-Off-By field and Change-ID field. """ relevant_commits_cmd, relevant_commits = get_relevant_commits( head, merge_head, "--oneline", rel_paths ) _, relevant_bugs = get_relevant_commits(head, merge_head, "", rel_paths) relevant_bugs = set(re.findall("BUG=(.*)", relevant_bugs)) # Filter out "none" from set of bugs filtered = [] for bug_line in relevant_bugs: bug_line = bug_line.replace(",", " ") bugs = bug_line.split(" ") for bug in bugs: if bug and not BUG_NONE_PATTERN.match(bug): filtered.append(bug) relevant_bugs = filtered # TODO(b/179509333): remove Cq-Include-Trybots line when regular CQ and # firmware CQ do not behave differently. commit_msg_template = """ Merge remote-tracking branch {CROS_MAIN} into {BRANCH} Generated by: {COMMAND_LINE} Relevant changes: {RELEVANT_COMMITS_CMD} {RELEVANT_COMMITS} BRANCH=None {BUG_FIELD} TEST=`make -j buildall` Force-Relevant-Builds: all """ # Wrap the commands and bug field such that we don't exceed 72 cols. relevant_commits_cmd = textwrap.fill(relevant_commits_cmd, width=72) cmd = textwrap.fill(cmd, width=72) # Wrap at 68 cols to save room for 'BUG=' bugs = textwrap.wrap(" ".join(relevant_bugs), width=68) bug_field = "" for line in bugs: bug_field += "BUG=" + line + "\n" # Remove the final newline since the template adds it for us. bug_field = bug_field[:-1] return commit_msg_template.format( CROS_MAIN=cros_main, BRANCH=branch, RELEVANT_COMMITS_CMD=relevant_commits_cmd, RELEVANT_COMMITS=relevant_commits, BUG_FIELD=bug_field, COMMAND_LINE=cmd, ) def get_relevant_boards(baseboard): """Searches the EC repo looking for boards that use the given baseboard. Args: baseboard: String containing the baseboard to consider Returns: A list of strings containing the boards based off of the baseboard. """ proc = subprocess.run( ["git", "grep", "BASEBOARD:=" + baseboard, "--", "board/"], stdout=subprocess.PIPE, encoding="utf-8", check=True, ) boards = [] res = proc.stdout.splitlines() for line in res: boards.append(line.split("/")[1]) return boards def get_relevant_commits(head, merge_head, fmt, relevant_paths): """Find relevant changes since last merge. Searches git history to find changes since the last merge which modify files present in relevant_paths. Args: head: String indicating the HEAD refspec merge_head: String indicating the merge branch refspec. fmt: An optional string containing the format option for `git log` relevant_paths: String containing all the relevant paths for this particular baseboard or board. Returns: A tuple containing the arguments passed to the git log command and stdout. """ if fmt: cmd = [ "git", "log", fmt, head + ".." + merge_head, "--", relevant_paths, ] else: cmd = ["git", "log", head + ".." + merge_head, "--", relevant_paths] # Pass cmd as a string to subprocess.run() since we need to run with shell # equal to True. The reason we are using shell equal to True is to take # advantage of the glob expansion for the relevant paths. cmd = " ".join(cmd) proc = subprocess.run( cmd, stdout=subprocess.PIPE, encoding="utf-8", check=True, shell=True ) return "".join(proc.args), proc.stdout def main(argv): """Generates a merge commit from ToT to a desired release branch. For the commit message, it finds all the commits that have modified a relevant path. By default this is the baseboard or board directory. The user may optionally specify a path to a text file which contains a longer list of relevant files. The format should be in the glob syntax that git log expects. Args: argv: A list of the command line arguments passed to this script. """ # Set up argument parser. parser = argparse.ArgumentParser( description=( "A script that generates a " "merge commit from cros/main" " to a desired release " "branch. By default, the " '"recursive" merge strategy ' 'with the "theirs" strategy ' "option is used." ) ) parser.add_argument("--baseboard") parser.add_argument("--board") parser.add_argument( "release_branch", help=("The name of the target release" " branch") ) parser.add_argument( "--remote_prefix", help=( "The name of the remote branch prefix (default cros). " "Private repos typically use cros-internal instead." ), default="cros", ) parser.add_argument( "--relevant_paths_file", help=( "A path to a text file which includes other " "relevant paths of interest for this board " "or baseboard" ), ) parser.add_argument( "--merge_strategy", "-s", default="recursive", help="The merge strategy to pass to `git merge -s`", ) parser.add_argument( "--strategy_option", "-X", help=("The strategy option for the chosen merge " "strategy"), ) parser.add_argument( "--remove_owners", "-r", action=("store_true"), help=("Remove non-root OWNERS level files if present"), ) opts = parser.parse_args(argv[1:]) baseboard_dir = "" board_dir = "" if opts.baseboard: # Dereference symlinks so "git log" works as expected. baseboard_dir = os.path.relpath("baseboard/" + opts.baseboard) baseboard_dir = os.path.relpath(os.path.realpath(baseboard_dir)) boards = get_relevant_boards(opts.baseboard) elif opts.board: board_dir = os.path.relpath("board/" + opts.board) board_dir = os.path.relpath(os.path.realpath(board_dir)) boards = [opts.board] else: boards = [] print("Gathering relevant paths...") relevant_paths = [] if opts.baseboard: relevant_paths.append(baseboard_dir) elif opts.board: relevant_paths.append(board_dir) for board in boards: relevant_paths.append("board/" + board) # Check for the existence of a file that has other paths of interest. if opts.relevant_paths_file and os.path.exists(opts.relevant_paths_file): with open(opts.relevant_paths_file, "r") as relevant_paths_file: for line in relevant_paths_file: if not line.startswith("#"): relevant_paths.append(line.rstrip()) if os.path.exists("util/getversion.sh"): relevant_paths.append("util/getversion.sh") relevant_paths = " ".join(relevant_paths) # Check if we are already in merge process result = subprocess.run( ["git", "rev-parse", "--quiet", "--verify", "MERGE_HEAD"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, ) # Prune OWNERS files if desired if opts.remove_owners: prunelist = [] for root, dirs, files in os.walk("."): for name in dirs: if "build" in name: continue for name in files: if "OWNERS" in name: path = os.path.join(root, name) prunelist.append(path[2:]) # Strip the "./" # Remove the top level OWNERS file from the prunelist. try: prunelist.remove("OWNERS") except ValueError: pass if prunelist: print("Not merging the following OWNERS files:") for path in prunelist: print(" " + path) if result.returncode: # Let's perform the merge print("Updating remote...") subprocess.run(["git", "remote", "update"], check=True) subprocess.run( [ "git", "checkout", "-B", opts.release_branch, opts.remote_prefix + "/" + opts.release_branch, ], check=True, ) print("Attempting git merge...") if opts.merge_strategy == "recursive" and not opts.strategy_option: opts.strategy_option = "theirs" print( 'Using "%s" merge strategy' % opts.merge_strategy, ( "with strategy option '%s'" % opts.strategy_option if opts.strategy_option else "" ), ) cros_main = opts.remote_prefix + "/" + "main" arglist = [ "git", "merge", "--no-ff", "--no-commit", cros_main, "-s", opts.merge_strategy, ] if opts.strategy_option: arglist.append("-X" + opts.strategy_option) try: subprocess.run(arglist, check=True) except: # We've likely encountered a merge conflict due to new OWNERS file # modifications. If we're removing the owners, we'll delete them. if opts.remove_owners and prunelist: # Find the unmerged files unmerged = ( subprocess.run( ["git", "diff", "--name-only", "--diff-filter=U"], stdout=subprocess.PIPE, encoding="utf-8", check=True, ) .stdout.rstrip() .split() ) # Prune OWNERS files for file in unmerged: if file in prunelist: subprocess.run(["git", "rm", file], check=False) unmerged.remove(file) print("Removed non-root OWNERS files.") if unmerged: print( "Unmerged files still exist! You need to manually resolve this." ) print("\n".join(unmerged)) sys.exit(1) else: raise else: print( "We have already started merge process.", "Attempt to generate commit.", ) print("Generating commit message...") branch = subprocess.run( ["git", "rev-parse", "--abbrev-ref", "HEAD"], stdout=subprocess.PIPE, encoding="utf-8", check=True, ).stdout.rstrip() head = subprocess.run( ["git", "rev-parse", "--short", "HEAD"], stdout=subprocess.PIPE, encoding="utf-8", check=True, ).stdout.rstrip() merge_head = subprocess.run( ["git", "rev-parse", "--short", "MERGE_HEAD"], stdout=subprocess.PIPE, encoding="utf-8", check=True, ).stdout.rstrip() cmd = " ".join(argv) print("Typing as fast as I can...") commit_msg = git_commit_msg( cros_main, branch, head, merge_head, relevant_paths, cmd ) subprocess.run(["git", "commit", "--signoff", "-m", commit_msg], check=True) subprocess.run(["git", "commit", "--amend"], check=True) print( ( "Finished! **Please review the commit to see if it's to your " "liking.**" ) ) if __name__ == "__main__": main(sys.argv)