| #!/usr/bin/env python3 |
| # Copyright 2017 The Chromium Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| """Splits a branch into smaller branches and uploads CLs.""" |
| |
| import collections |
| import os |
| import re |
| import subprocess2 |
| import sys |
| |
| import gclient_utils |
| import git_footers |
| import scm |
| |
| import git_common as git |
| |
| # If a call to `git cl split` will generate more than this number of CLs, the |
| # command will prompt the user to make sure they know what they're doing. Large |
| # numbers of CLs generated by `git cl split` have caused infrastructure issues |
| # in the past. |
| CL_SPLIT_FORCE_LIMIT = 10 |
| |
| # The maximum number of top reviewers to list. `git cl split` may send many CLs |
| # to a single reviewer, so the top reviewers with the most CLs sent to them |
| # will be listed. |
| CL_SPLIT_TOP_REVIEWERS = 5 |
| |
| FilesAndOwnersDirectory = collections.namedtuple("FilesAndOwnersDirectory", |
| "files owners_directories") |
| |
| |
| def EnsureInGitRepository(): |
| """Throws an exception if the current directory is not a git repository.""" |
| git.run('rev-parse') |
| |
| |
| def CreateBranchForDirectories(prefix, directories, upstream): |
| """Creates a branch named |prefix| + "_" + |directories[0]| + "_split". |
| |
| Return false if the branch already exists. |upstream| is used as upstream |
| for the created branch. |
| """ |
| existing_branches = set(git.branches(use_limit=False)) |
| branch_name = prefix + '_' + directories[0] + '_split' |
| if branch_name in existing_branches: |
| return False |
| git.run('checkout', '-t', upstream, '-b', branch_name) |
| return True |
| |
| |
| def FormatDirectoriesForPrinting(directories, prefix=None): |
| """Formats directory list for printing |
| |
| Uses dedicated format for single-item list.""" |
| |
| prefixed = directories |
| if prefix: |
| prefixed = [(prefix + d) for d in directories] |
| |
| return str(prefixed) if len(prefixed) > 1 else str(prefixed[0]) |
| |
| |
| def FormatDescriptionOrComment(txt, directories): |
| """Replaces $directory with |directories| in |txt|.""" |
| to_insert = FormatDirectoriesForPrinting(directories, prefix='/') |
| return txt.replace('$directory', to_insert) |
| |
| |
| def AddUploadedByGitClSplitToDescription(description): |
| """Adds a 'This CL was uploaded by git cl split.' line to |description|. |
| |
| The line is added before footers, or at the end of |description| if it has |
| no footers. |
| """ |
| split_footers = git_footers.split_footers(description) |
| lines = split_footers[0] |
| if lines[-1] and not lines[-1].isspace(): |
| lines = lines + [''] |
| lines = lines + ['This CL was uploaded by git cl split.'] |
| if split_footers[1]: |
| lines += [''] + split_footers[1] |
| return '\n'.join(lines) |
| |
| |
| def UploadCl(refactor_branch, refactor_branch_upstream, directories, files, |
| description, comment, reviewers, changelist, cmd_upload, |
| cq_dry_run, enable_auto_submit, topic, repository_root): |
| """Uploads a CL with all changes to |files| in |refactor_branch|. |
| |
| Args: |
| refactor_branch: Name of the branch that contains the changes to upload. |
| refactor_branch_upstream: Name of the upstream of |refactor_branch|. |
| directories: Paths to the directories that contain the OWNERS files for |
| which to upload a CL. |
| files: List of AffectedFile instances to include in the uploaded CL. |
| description: Description of the uploaded CL. |
| comment: Comment to post on the uploaded CL. |
| reviewers: A set of reviewers for the CL. |
| changelist: The Changelist class. |
| cmd_upload: The function associated with the git cl upload command. |
| cq_dry_run: If CL uploads should also do a cq dry run. |
| enable_auto_submit: If CL uploads should also enable auto submit. |
| topic: Topic to associate with uploaded CLs. |
| """ |
| # Create a branch. |
| if not CreateBranchForDirectories(refactor_branch, directories, |
| refactor_branch_upstream): |
| print('Skipping ' + FormatDirectoriesForPrinting(directories) + |
| ' for which a branch already exists.') |
| return |
| |
| # Checkout all changes to files in |files|. |
| deleted_files = [] |
| modified_files = [] |
| for action, f in files: |
| abspath = os.path.abspath(os.path.join(repository_root, f)) |
| if action == 'D': |
| deleted_files.append(abspath) |
| else: |
| modified_files.append(abspath) |
| |
| if deleted_files: |
| git.run(*['rm'] + deleted_files) |
| if modified_files: |
| git.run(*['checkout', refactor_branch, '--'] + modified_files) |
| |
| # Commit changes. The temporary file is created with delete=False so that it |
| # can be deleted manually after git has read it rather than automatically |
| # when it is closed. |
| with gclient_utils.temporary_file() as tmp_file: |
| gclient_utils.FileWrite( |
| tmp_file, FormatDescriptionOrComment(description, directories)) |
| git.run('commit', '-F', tmp_file) |
| |
| # Upload a CL. |
| upload_args = ['-f'] |
| if reviewers: |
| upload_args.extend(['-r', ','.join(sorted(reviewers))]) |
| if cq_dry_run: |
| upload_args.append('--cq-dry-run') |
| if not comment: |
| upload_args.append('--send-mail') |
| if enable_auto_submit: |
| upload_args.append('--enable-auto-submit') |
| if topic: |
| upload_args.append('--topic={}'.format(topic)) |
| print('Uploading CL for ' + FormatDirectoriesForPrinting(directories) + |
| '...') |
| |
| ret = cmd_upload(upload_args) |
| if ret != 0: |
| print('Uploading failed.') |
| print('Note: git cl split has built-in resume capabilities.') |
| print('Delete ' + git.current_branch() + |
| ' then run git cl split again to resume uploading.') |
| |
| if comment: |
| changelist().AddComment(FormatDescriptionOrComment( |
| comment, directories), |
| publish=True) |
| |
| |
| def GetFilesSplitByOwners(files, max_depth): |
| """Returns a map of files split by OWNERS file. |
| |
| Returns: |
| A map where keys are paths to directories containing an OWNERS file and |
| values are lists of files sharing an OWNERS file. |
| """ |
| files_split_by_owners = {} |
| for action, path in files: |
| # normpath() is important to normalize separators here, in prepration |
| # for str.split() before. It would be nicer to use something like |
| # pathlib here but alas... |
| dir_with_owners = os.path.normpath(os.path.dirname(path)) |
| if max_depth >= 1: |
| dir_with_owners = os.path.join( |
| *dir_with_owners.split(os.path.sep)[:max_depth]) |
| # Find the closest parent directory with an OWNERS file. |
| while (dir_with_owners not in files_split_by_owners |
| and not os.path.isfile(os.path.join(dir_with_owners, 'OWNERS'))): |
| dir_with_owners = os.path.dirname(dir_with_owners) |
| files_split_by_owners.setdefault(dir_with_owners, []).append( |
| (action, path)) |
| return files_split_by_owners |
| |
| |
| def PrintClInfo(cl_index, num_cls, directories, file_paths, description, |
| reviewers, cq_dry_run, enable_auto_submit, topic): |
| """Prints info about a CL. |
| |
| Args: |
| cl_index: The index of this CL in the list of CLs to upload. |
| num_cls: The total number of CLs that will be uploaded. |
| directories: Paths to directories that contains the OWNERS files for |
| which to upload a CL. |
| file_paths: A list of files in this CL. |
| description: The CL description. |
| reviewers: A set of reviewers for this CL. |
| cq_dry_run: If the CL should also be sent to CQ dry run. |
| enable_auto_submit: If the CL should also have auto submit enabled. |
| topic: Topic to set for this CL. |
| """ |
| description_lines = FormatDescriptionOrComment(description, |
| directories).splitlines() |
| indented_description = '\n'.join([' ' + l for l in description_lines]) |
| |
| print('CL {}/{}'.format(cl_index, num_cls)) |
| print('Paths: {}'.format(FormatDirectoriesForPrinting(directories))) |
| print('Reviewers: {}'.format(', '.join(reviewers))) |
| print('Auto-Submit: {}'.format(enable_auto_submit)) |
| print('CQ Dry Run: {}'.format(cq_dry_run)) |
| print('Topic: {}'.format(topic)) |
| print('\n' + indented_description + '\n') |
| print('\n'.join(file_paths)) |
| print() |
| |
| |
| def SplitCl(description_file, comment_file, changelist, cmd_upload, dry_run, |
| cq_dry_run, enable_auto_submit, max_depth, topic, repository_root): |
| """"Splits a branch into smaller branches and uploads CLs. |
| |
| Args: |
| description_file: File containing the description of uploaded CLs. |
| comment_file: File containing the comment of uploaded CLs. |
| changelist: The Changelist class. |
| cmd_upload: The function associated with the git cl upload command. |
| dry_run: Whether this is a dry run (no branches or CLs created). |
| cq_dry_run: If CL uploads should also do a cq dry run. |
| enable_auto_submit: If CL uploads should also enable auto submit. |
| max_depth: The maximum directory depth to search for OWNERS files. A |
| value less than 1 means no limit. |
| topic: Topic to associate with split CLs. |
| |
| Returns: |
| 0 in case of success. 1 in case of error. |
| """ |
| description = AddUploadedByGitClSplitToDescription( |
| gclient_utils.FileRead(description_file)) |
| comment = gclient_utils.FileRead(comment_file) if comment_file else None |
| |
| try: |
| EnsureInGitRepository() |
| |
| cl = changelist() |
| upstream = cl.GetCommonAncestorWithUpstream() |
| files = [ |
| (action.strip(), f) |
| for action, f in scm.GIT.CaptureStatus(repository_root, upstream) |
| ] |
| |
| if not files: |
| print('Cannot split an empty CL.') |
| return 1 |
| |
| author = git.run('config', 'user.email').strip() or None |
| refactor_branch = git.current_branch() |
| assert refactor_branch, "Can't run from detached branch." |
| refactor_branch_upstream = git.upstream(refactor_branch) |
| assert refactor_branch_upstream, \ |
| "Branch %s must have an upstream." % refactor_branch |
| |
| if not CheckDescriptionBugLink(description): |
| return 0 |
| |
| files_split_by_reviewers = SelectReviewersForFiles( |
| cl, author, files, max_depth) |
| |
| num_cls = len(files_split_by_reviewers) |
| print('Will split current branch (' + refactor_branch + ') into ' + |
| str(num_cls) + ' CLs.\n') |
| if not dry_run and num_cls > CL_SPLIT_FORCE_LIMIT: |
| print( |
| 'This will generate "%r" CLs. This many CLs can potentially' |
| ' generate too much load on the build infrastructure.\n\n' |
| 'Please email infra-dev@chromium.org to ensure that this won\'t' |
| ' break anything. The infra team reserves the right to cancel' |
| ' your jobs if they are overloading the CQ.\n\n' |
| '(Alternatively, you can reduce the number of CLs created by' |
| ' using the --max-depth option. Pass --dry-run to examine the' |
| ' CLs which will be created until you are happy with the' |
| ' results.)' % num_cls) |
| answer = gclient_utils.AskForData('Proceed? (y/n):') |
| if answer.lower() != 'y': |
| return 0 |
| |
| cls_per_reviewer = collections.defaultdict(int) |
| for cl_index, (reviewers, cl_info) in \ |
| enumerate(files_split_by_reviewers.items(), 1): |
| # Convert reviewers from tuple to set. |
| reviewer_set = set(reviewers) |
| if dry_run: |
| file_paths = [f for _, f in cl_info.files] |
| PrintClInfo(cl_index, num_cls, cl_info.owners_directories, |
| file_paths, description, reviewer_set, cq_dry_run, |
| enable_auto_submit, topic) |
| else: |
| UploadCl(refactor_branch, refactor_branch_upstream, |
| cl_info.owners_directories, cl_info.files, description, |
| comment, reviewer_set, changelist, cmd_upload, |
| cq_dry_run, enable_auto_submit, topic, repository_root) |
| |
| for reviewer in reviewers: |
| cls_per_reviewer[reviewer] += 1 |
| |
| # List the top reviewers that will be sent the most CLs as a result of |
| # the split. |
| reviewer_rankings = sorted(cls_per_reviewer.items(), |
| key=lambda item: item[1], |
| reverse=True) |
| print('The top reviewers are:') |
| for reviewer, count in reviewer_rankings[:CL_SPLIT_TOP_REVIEWERS]: |
| print(f' {reviewer}: {count} CLs') |
| |
| # Go back to the original branch. |
| git.run('checkout', refactor_branch) |
| |
| except subprocess2.CalledProcessError as cpe: |
| sys.stderr.write(cpe.stderr) |
| return 1 |
| return 0 |
| |
| |
| def CheckDescriptionBugLink(description): |
| """Verifies that the description contains a bug link. |
| |
| Examples: |
| Bug: 123 |
| Bug: chromium:456 |
| |
| Prompts user if the description does not contain a bug link. |
| """ |
| bug_pattern = re.compile(r"^Bug:\s*(?:[a-zA-Z]+:)?[0-9]+", re.MULTILINE) |
| matches = re.findall(bug_pattern, description) |
| answer = 'y' |
| if not matches: |
| answer = gclient_utils.AskForData( |
| 'Description does not include a bug link. Proceed? (y/n):') |
| return answer.lower() == 'y' |
| |
| |
| def SelectReviewersForFiles(cl, author, files, max_depth): |
| """Selects reviewers for passed-in files |
| |
| Args: |
| cl: Changelist class instance |
| author: Email of person running 'git cl split' |
| files: List of files |
| max_depth: The maximum directory depth to search for OWNERS files. |
| A value less than 1 means no limit. |
| """ |
| info_split_by_owners = GetFilesSplitByOwners(files, max_depth) |
| |
| info_split_by_reviewers = {} |
| |
| for (directory, split_files) in info_split_by_owners.items(): |
| # Use '/' as a path separator in the branch name and the CL description |
| # and comment. |
| directory = directory.replace(os.path.sep, '/') |
| file_paths = [f for _, f in split_files] |
| # Convert reviewers list to tuple in order to use reviewers as key to |
| # dictionary. |
| reviewers = tuple( |
| cl.owners_client.SuggestOwners( |
| file_paths, exclude=[author, cl.owners_client.EVERYONE])) |
| |
| if not reviewers in info_split_by_reviewers: |
| info_split_by_reviewers[reviewers] = FilesAndOwnersDirectory([], []) |
| info_split_by_reviewers[reviewers].files.extend(split_files) |
| info_split_by_reviewers[reviewers].owners_directories.append(directory) |
| |
| return info_split_by_reviewers |