123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775 |
- #!/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 dataclasses
- import hashlib
- import os
- import re
- import tempfile
- from typing import List, Set, Tuple, Dict, Any
- 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
- def Emit(*msg: str):
- """Wrapper for easier mocking during tests"""
- print(*msg)
- def EmitWarning(*msg: str):
- print("Warning: ", *msg)
- def HashList(lst: List[Any]) -> str:
- """
- Hash a list, returning a positive integer. Lists with identical elements
- should have the same hash, regardless of order.
- """
- # We need a bytes-like object for hashlib algorithms
- byts = bytes().join(
- (action + file).encode() for action, file in sorted(lst))
- # No security implication: we just need a deterministic output
- hashed = hashlib.sha1(byts)
- return hashed.hexdigest()[:10]
- FilesAndOwnersDirectory = collections.namedtuple("FilesAndOwnersDirectory",
- "files owners_directories")
- @dataclasses.dataclass
- class CLInfo:
- """
- Data structure representing a single CL. The script will split the large CL
- into a list of these.
- Fields:
- - reviewers: the reviewers the CL will be sent to.
- - files: a list of <action>, <file> pairs in the CL.
- Has the same format as `git status`.
- - description: a string describing the CL. Typically the list of affected
- directories. Only used for replacing $description in
- the user-provided CL description.
- """
- # Have to use default_factory because lists are mutable
- reviewers: Set[str] = dataclasses.field(default_factory=set)
- files: List[Tuple[str, str]] = dataclasses.field(default_factory=list)
- # This is only used for formatting in the CL description, so it just
- # has to be convertible to string.
- description: Any = ""
- def FormatForPrinting(self) -> str:
- """
- Format the CLInfo for printing to a file in a human-readable format.
- """
- # Don't quote the reviewer emails in the output
- reviewers_str = ", ".join(self.reviewers)
- lines = [
- f"Reviewers: [{reviewers_str}]", f"Description: {self.description}"
- ] + [f"{action}, {file}" for (action, file) in self.files]
- return "\n".join(lines)
- def CLInfoFromFilesAndOwnersDirectoriesDict(
- d: Dict[Tuple[str], FilesAndOwnersDirectory]) -> List[CLInfo]:
- """
- Transform a dictionary mapping reviewer tuples to FilesAndOwnersDirectories
- into a list of CLInfo
- """
- cl_infos = []
- for (reviewers, fod) in d.items():
- cl_infos.append(
- CLInfo(set(reviewers), fod.files,
- FormatDirectoriesForPrinting(fod.owners_directories)))
- return cl_infos
- def EnsureInGitRepository():
- """Throws an exception if the current directory is not a git repository."""
- git.run('rev-parse')
- def CreateBranchName(prefix: str, files: List[Tuple[str, str]]) -> str:
- """
- Given a sub-CL as a list of (action, file) pairs, create a unique and
- deterministic branch name for it.
- The name has the format <prefix>_<dirname>_<hash(files)>_split.
- """
- file_names = [file for _, file in files]
- if len(file_names) == 1:
- # Only one file, just use its directory as the common path
- common_path = os.path.dirname(file_names[0])
- else:
- common_path = os.path.commonpath(file_names)
- if not common_path:
- # Files have nothing in common at all. Unlikely but possible.
- common_path = "None"
- # Replace path delimiter with underscore in common_path.
- common_path = common_path.replace(os.path.sep, '_')
- return f"{prefix}_{HashList(files)}_{common_path}_split"
- def CreateBranchForOneCL(prefix: str, files: List[Tuple[str, str]],
- upstream: str) -> bool:
- """Creates a branch named |prefix| + "_" + |hash(files)| + "_split".
- Return false if the branch already exists. |upstream| is used as upstream
- for the created branch.
- """
- branches_on_disk = set(git.branches(use_limit=False))
- branch_name = CreateBranchName(prefix, files)
- if branch_name in branches_on_disk:
- return False
- git.run('checkout', '-t', upstream, '-b', branch_name)
- return True
- def ValidateExistingBranches(prefix: str, cl_infos: List[CLInfo]) -> bool:
- """
- Check if there are splitting branches left over from a previous run.
- We only allow branches to exist if we're resuming a previous upload,
- in which case we require that the existing branches are a subset of
- the branches we're going to generate.
- """
- branches_on_disk = set(
- branch for branch in git.branches(use_limit=False)
- if branch.startswith(prefix + "_") and branch.endswith("_split"))
- branches_to_be_made = set(
- CreateBranchName(prefix, info.files) for info in cl_infos)
- if not branches_on_disk.issubset(branches_to_be_made):
- Emit("It seems like you've already run `git cl split` on this branch.\n"
- "If you're resuming a previous upload, you must pass in the "
- "same splitting as before, using the --from-file option.\n"
- "If you're starting a new upload, please clean up existing split "
- f"branches (starting with '{prefix}_' and ending with '_split'), "
- "and re-run the tool.")
- Emit("The following branches need to be cleaned up:\n")
- for branch in branches_on_disk - branches_to_be_made:
- Emit(branch)
- return False
- return True
- def FormatDirectoriesForPrinting(directories: List[str],
- prefix: str = None) -> str:
- """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[0]) if len(prefixed) == 1 else str(prefixed)
- def FormatDescriptionOrComment(txt, desc):
- """Replaces $description with |desc| in |txt|."""
- # TODO(389069356): Remove support for $directory entirely once it's been
- # deprecated for a while.
- replaced_txt = txt.replace('$directory', desc)
- if txt != replaced_txt:
- EmitWarning('Usage of $directory is deprecated and will be removed '
- 'in a future update. Please use $description instead, '
- 'which has the same behavior by default.\n\n')
- replaced_txt = replaced_txt.replace('$description', desc)
- return replaced_txt
- 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, cl_description, files,
- user_description, saved_splitting_file, 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|.
- cl_description: Description of this specific CL, e.g. the list of
- affected directories.
- files: List of AffectedFile instances to include in the uploaded CL.
- user_description: Description provided by user.
- 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 CreateBranchForOneCL(refactor_branch, files,
- refactor_branch_upstream):
- Emit(
- f'Skipping existing branch for CL with description: {cl_description}'
- )
- 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(user_description, cl_description))
- 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))
- Emit(f'Uploading CL with description: {cl_description} ...')
- ret = cmd_upload(upload_args)
- if ret != 0:
- Emit('Uploading failed.')
- Emit('Note: git cl split has built-in resume capabilities.')
- Emit(f'Delete {git.current_branch()} then run\n'
- f'git cl split --from-file={saved_splitting_file}\n'
- 'to resume uploading.')
- if comment:
- changelist().AddComment(FormatDescriptionOrComment(
- comment, cl_description),
- 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, cl_description, file_paths, user_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.
- cl_description: Description of this specific CL, e.g. the list of
- affected directories.
- file_paths: A list of files in this CL.
- user_description: Description provided by user.
- 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(user_description,
- cl_description).splitlines()
- indented_description = '\n'.join([' ' + l for l in description_lines])
- Emit('CL {}/{}'.format(cl_index, num_cls))
- Emit('Paths: {}'.format(cl_description))
- Emit('Reviewers: {}'.format(', '.join(reviewers)))
- Emit('Auto-Submit: {}'.format(enable_auto_submit))
- Emit('CQ Dry Run: {}'.format(cq_dry_run))
- Emit('Topic: {}'.format(topic))
- Emit('\n' + indented_description + '\n')
- Emit('\n'.join(file_paths))
- def LoadDescription(description_file, dry_run):
- if not description_file:
- if not dry_run:
- # Parser checks this as well, so should be impossible
- raise ValueError(
- "Must provide a description file except during dry runs")
- return ('Dummy description for dry run.\n'
- 'description = $description')
- return gclient_utils.FileRead(description_file)
- def PrintSummary(cl_infos, refactor_branch):
- """Print a brief summary of the splitting so the user
- can review it before uploading.
- Args:
- files_split_by_reviewers: A dictionary mapping reviewer tuples
- to the files and directories assigned to them.
- """
- for info in cl_infos:
- Emit(f'Reviewers: {info.reviewers}, files: {len(info.files)}, '
- f'description: {info.description}')
- num_cls = len(cl_infos)
- Emit(f'\nWill split branch {refactor_branch} into {num_cls} CLs. '
- 'Please quickly review them before proceeding.\n')
- if (num_cls > CL_SPLIT_FORCE_LIMIT):
- EmitWarning(
- 'Uploading this many CLs may potentially '
- 'reach the limit of concurrent runs, imposed on you by the '
- 'build infrastructure. Your runs may be throttled as a '
- 'result.\n\nPlease email infra-dev@chromium.org if you '
- 'have any questions. '
- '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.)')
- def SplitCl(description_file, comment_file, changelist, cmd_upload, dry_run,
- summarize, reviewers_override, cq_dry_run, enable_auto_submit,
- max_depth, topic, from_file, 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).
- reviewers_override: Either None or a (possibly empty) list of reviewers
- all CLs should be sent to.
- 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 = LoadDescription(description_file, dry_run)
- description = AddUploadedByGitClSplitToDescription(description)
- comment = gclient_utils.FileRead(comment_file) if comment_file else None
- EnsureInGitRepository()
- cl = changelist()
- upstream = cl.GetCommonAncestorWithUpstream()
- files = [(action.strip(), f)
- for action, f in scm.GIT.CaptureStatus(repository_root, upstream)]
- if not files:
- Emit('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 dry_run and not CheckDescriptionBugLink(description):
- return 0
- if from_file:
- cl_infos = LoadSplittingFromFile(from_file, files_on_disk=files)
- else:
- files_split_by_reviewers = SelectReviewersForFiles(
- cl, author, files, max_depth)
- cl_infos = CLInfoFromFilesAndOwnersDirectoriesDict(
- files_split_by_reviewers)
- # Note that we do this override even if the list is empty (indicating that
- # the user requested CLs not be assigned to any reviewers).
- if reviewers_override != None:
- for info in cl_infos:
- info.reviewers = set(reviewers_override)
- if not dry_run:
- PrintSummary(cl_infos, refactor_branch)
- answer = gclient_utils.AskForData(
- 'Proceed? (y/N, or i to edit interactively): ')
- if answer.lower() == 'i':
- cl_infos, saved_splitting_file = EditSplittingInteractively(
- cl_infos, files_on_disk=files)
- else:
- # Save even if we're continuing, so the user can safely resume an
- # aborted upload with the same splitting
- saved_splitting_file = SaveSplittingToTempFile(cl_infos)
- if answer.lower() != 'y':
- return 0
- # Make sure there isn't any clutter left over from a previous run
- if not ValidateExistingBranches(refactor_branch, cl_infos):
- return 0
- elif summarize:
- PrintSummary(cl_infos, refactor_branch)
- cls_per_reviewer = collections.defaultdict(int)
- for cl_index, cl_info in enumerate(cl_infos, 1):
- if dry_run and summarize:
- pass
- elif dry_run:
- file_paths = [f for _, f in cl_info.files]
- PrintClInfo(cl_index, len(cl_infos), cl_info.description,
- file_paths, description, cl_info.reviewers, cq_dry_run,
- enable_auto_submit, topic)
- else:
- UploadCl(refactor_branch, refactor_branch_upstream,
- cl_info.description, cl_info.files, description,
- saved_splitting_file, comment, cl_info.reviewers,
- changelist, cmd_upload, cq_dry_run, enable_auto_submit,
- topic, repository_root)
- for reviewer in cl_info.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)
- Emit('The top reviewers are:')
- for reviewer, count in reviewer_rankings[:CL_SPLIT_TOP_REVIEWERS]:
- Emit(f' {reviewer}: {count} CLs')
- if dry_run:
- # Wait until now to save the splitting so the file name doesn't get
- # washed away by the flood of dry-run printing.
- SaveSplittingToTempFile(cl_infos)
- # Go back to the original branch.
- git.run('checkout', refactor_branch)
- 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
- def SaveSplittingToFile(cl_infos: List[CLInfo], filename: str, silent=False):
- """
- Writes the listed CLs to the designated file, in a human-readable and
- editable format. Include an explanation of the file format at the top,
- as well as instructions for how to use it.
- """
- preamble = (
- "# CLs in this file must have the following format:\n"
- "# A 'Reviewers: [...]' line, where '...' is a (possibly empty) list "
- "of reviewer emails.\n"
- "# A 'Description: ...' line, where '...' is any string (by default, "
- "the list of directories the files have been pulled from).\n"
- "# One or more file lines, consisting of an <action>, <file> pair, in "
- "the format output by `git status`.\n\n"
- "# Each 'Reviewers' line begins a new CL.\n"
- "# To use the splitting in this file, use the --from-file option.\n\n")
- cl_string = "\n\n".join([info.FormatForPrinting() for info in cl_infos])
- gclient_utils.FileWrite(filename, preamble + cl_string)
- if not silent:
- Emit(f"Saved splitting to {filename}")
- def SaveSplittingToTempFile(cl_infos: List[CLInfo], silent=False):
- """
- Create a file in the user's temp directory, and save the splitting there.
- """
- # We can't use gclient_utils.temporary_file because it will be removed
- temp_file, temp_name = tempfile.mkstemp(prefix="split_cl_")
- os.close(temp_file) # Necessary for windows
- SaveSplittingToFile(cl_infos, temp_name, silent)
- return temp_name
- class ClSplitParseError(Exception):
- pass
- # Matches 'Reviewers: [...]', extracts the ...
- reviewers_re = re.compile(r'Reviewers:\s*\[([^\]]*)\]')
- # Matches 'Description: ...', extracts the ...
- description_re = re.compile(r'Description:\s*(.+)')
- # Matches '<action>, <file>', and extracts both
- # <action> must be a valid code (either 1 or 2 letters)
- file_re = re.compile(r'([MTADRC]{1,2}),\s*(.+)')
- # We use regex parsing instead of e.g. json because it lets us use a much more
- # human-readable format, similar to the summary printed in dry runs
- def ParseSplittings(lines: List[str]) -> List[CLInfo]:
- """
- Parse a splitting file. We expect to get a series of lines in the format
- of CLInfo.FormatForPrinting. In the following order, we expect to see
- - A 'Reviewers: ' line containing a list,
- - A 'Description: ' line containing anything, and
- - A list of <action>, <path> pairs, each on its own line
- Note that this function only transforms the file into a list of CLInfo
- (if possible). It does not validate the information; for that, see
- ValidateSplitting.
- """
- cl_infos = []
- current_cl_info = None
- for line in lines:
- line = line.strip()
- # Skip empty or commented lines
- if not line or line.startswith('#'):
- continue
- # Start a new CL whenever we see a new Reviewers: line
- m = re.fullmatch(reviewers_re, line)
- if m:
- reviewers_str = m.group(1)
- reviewers = [r.strip() for r in reviewers_str.split(",")]
- # Account for empty list or trailing comma
- if not reviewers[-1]:
- reviewers = reviewers[:-1]
- if current_cl_info:
- cl_infos.append(current_cl_info)
- current_cl_info = CLInfo(reviewers=reviewers)
- continue
- if not current_cl_info:
- # Make sure no nonempty lines appear before the first CL
- raise ClSplitParseError(
- f"Error: Line appears before the first 'Reviewers: ' line:\n{line}"
- )
- # Description is just used as a description, so any string is fine
- m = re.fullmatch(description_re, line)
- if m:
- if current_cl_info.description:
- raise ClSplitParseError(
- f"Error parsing line: CL already has a description entry\n{line}"
- )
- current_cl_info.description = m.group(1).strip()
- continue
- # Any other line is presumed to be an '<action>, <file>' pair
- m = re.fullmatch(file_re, line)
- if m:
- action, path = m.groups()
- current_cl_info.files.append((action, path))
- continue
- raise ClSplitParseError("Error parsing line: Does not look like\n"
- "'Reviewers: [...]',\n"
- "'Description: ...', or\n"
- f"a pair of '<action>, <file>':\n{line}")
- if (current_cl_info):
- cl_infos.append(current_cl_info)
- return cl_infos
- def ValidateSplitting(cl_infos: List[CLInfo], filename: str,
- files_on_disk: List[Tuple[str, str]]):
- """
- Ensure that the provided list of CLs is a valid splitting.
- Specifically, check that:
- - Each file is in at most one CL
- - Each file and action appear in the list of changed files reported by git
- - Warn if some files don't appear in any CL
- - Warn if a reviewer string looks wrong, or if a CL is empty
- """
- # Validate the parsed information
- if not cl_infos:
- EmitWarning("No CLs listed in file. No action will be taken.")
- return []
- files_in_loaded_cls = set()
- # Collect all files, ensuring no duplicates
- # Warn on empty CLs or invalid reviewer strings
- for info in cl_infos:
- if not info.files:
- EmitWarning("CL has no files, and will be skipped:\n",
- info.FormatForPrinting())
- for file_info in info.files:
- if file_info in files_in_loaded_cls:
- raise ClSplitParseError(
- f"File appears in multiple CLs in {filename}:\n{file_info}")
- files_in_loaded_cls.add(file_info)
- for reviewer in info.reviewers:
- if not (re.fullmatch(r"[^@]+@[^.]+\..+", reviewer)):
- EmitWarning("reviewer does not look like an email address: ",
- reviewer)
- # Strip empty CLs
- cl_infos = [info for info in cl_infos if info.files]
- # Ensure the files in the user-provided CL splitting match the files
- # that git reports.
- # Warn if not all the files git reports appear.
- # Fail if the user mentions a file that isn't reported by git
- files_on_disk = set(files_on_disk)
- if not files_in_loaded_cls.issubset(files_on_disk):
- extra_files = files_in_loaded_cls.difference(files_on_disk)
- extra_files_str = "\n".join(f"{action}, {file}"
- for (action, file) in extra_files)
- raise ClSplitParseError(
- f"Some files are listed in {filename} but do not match any files "
- f"listed by git:\n{extra_files_str}")
- unmentioned_files = files_on_disk.difference(files_in_loaded_cls)
- if (unmentioned_files):
- EmitWarning(
- "the following files are not included in any CL in {filename}. "
- "They will not be uploaded:")
- for file in unmentioned_files:
- Emit(file)
- def LoadSplittingFromFile(filename: str,
- files_on_disk: List[Tuple[str, str]]) -> List[CLInfo]:
- """
- Given a file and the list of <action>, <file> pairs reported by git,
- read the file and return the list of CLInfos it contains.
- """
- lines = gclient_utils.FileRead(filename).splitlines()
- cl_infos = ParseSplittings(lines)
- ValidateSplitting(cl_infos, filename, files_on_disk)
- return cl_infos
- def EditSplittingInteractively(
- cl_infos: List[CLInfo],
- files_on_disk: List[Tuple[str, str]]) -> List[CLInfo]:
- """
- Allow the user to edit the generated splitting using their default editor.
- Make sure the edited splitting is saved so they can retrieve it if needed.
- """
- tmp_file = SaveSplittingToTempFile(cl_infos, silent=True)
- splitting = gclient_utils.RunEditor(gclient_utils.FileRead(tmp_file), False)
- cl_infos = ParseSplittings(splitting.splitlines())
- # Save the edited splitting before validation, so the user can go back
- # and edit it if there are any typos
- SaveSplittingToFile(cl_infos, tmp_file)
- ValidateSplitting(cl_infos, "the provided splitting", files_on_disk)
- return cl_infos, tmp_file
|