split_cl.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. #!/usr/bin/env python
  2. # Copyright 2017 The Chromium Authors. All rights reserved.
  3. # Use of this source code is governed by a BSD-style license that can be
  4. # found in the LICENSE file.
  5. """Splits a branch into smaller branches and uploads CLs."""
  6. from __future__ import print_function
  7. import collections
  8. import os
  9. import re
  10. import subprocess2
  11. import sys
  12. import tempfile
  13. import gclient_utils
  14. import git_footers
  15. import owners
  16. import owners_finder
  17. import scm
  18. import git_common as git
  19. # If a call to `git cl split` will generate more than this number of CLs, the
  20. # command will prompt the user to make sure they know what they're doing. Large
  21. # numbers of CLs generated by `git cl split` have caused infrastructure issues
  22. # in the past.
  23. CL_SPLIT_FORCE_LIMIT = 10
  24. def ReadFile(file_path):
  25. """Returns the content of |file_path|."""
  26. with open(file_path) as f:
  27. content = f.read()
  28. return content
  29. def EnsureInGitRepository():
  30. """Throws an exception if the current directory is not a git repository."""
  31. git.run('rev-parse')
  32. def CreateBranchForDirectory(prefix, directory, upstream):
  33. """Creates a branch named |prefix| + "_" + |directory| + "_split".
  34. Return false if the branch already exists. |upstream| is used as upstream for
  35. the created branch.
  36. """
  37. existing_branches = set(git.branches(use_limit = False))
  38. branch_name = prefix + '_' + directory + '_split'
  39. if branch_name in existing_branches:
  40. return False
  41. git.run('checkout', '-t', upstream, '-b', branch_name)
  42. return True
  43. def FormatDescriptionOrComment(txt, directory):
  44. """Replaces $directory with |directory| in |txt|."""
  45. return txt.replace('$directory', '/' + directory)
  46. def AddUploadedByGitClSplitToDescription(description):
  47. """Adds a 'This CL was uploaded by git cl split.' line to |description|.
  48. The line is added before footers, or at the end of |description| if it has no
  49. footers.
  50. """
  51. split_footers = git_footers.split_footers(description)
  52. lines = split_footers[0]
  53. if not lines[-1] or lines[-1].isspace():
  54. lines = lines + ['']
  55. lines = lines + ['This CL was uploaded by git cl split.']
  56. if split_footers[1]:
  57. lines += [''] + split_footers[1]
  58. return '\n'.join(lines)
  59. def UploadCl(refactor_branch, refactor_branch_upstream, directory, files,
  60. description, comment, reviewers, changelist, cmd_upload,
  61. cq_dry_run, enable_auto_submit, repository_root):
  62. """Uploads a CL with all changes to |files| in |refactor_branch|.
  63. Args:
  64. refactor_branch: Name of the branch that contains the changes to upload.
  65. refactor_branch_upstream: Name of the upstream of |refactor_branch|.
  66. directory: Path to the directory that contains the OWNERS file for which
  67. to upload a CL.
  68. files: List of AffectedFile instances to include in the uploaded CL.
  69. description: Description of the uploaded CL.
  70. comment: Comment to post on the uploaded CL.
  71. reviewers: A set of reviewers for the CL.
  72. changelist: The Changelist class.
  73. cmd_upload: The function associated with the git cl upload command.
  74. cq_dry_run: If CL uploads should also do a cq dry run.
  75. enable_auto_submit: If CL uploads should also enable auto submit.
  76. """
  77. # Create a branch.
  78. if not CreateBranchForDirectory(
  79. refactor_branch, directory, refactor_branch_upstream):
  80. print('Skipping ' + directory + ' for which a branch already exists.')
  81. return
  82. # Checkout all changes to files in |files|.
  83. deleted_files = []
  84. modified_files = []
  85. for action, f in files:
  86. abspath = os.path.abspath(os.path.join(repository_root, f))
  87. if action == 'D':
  88. deleted_files.append(abspath)
  89. else:
  90. modified_files.append(abspath)
  91. if deleted_files:
  92. git.run(*['rm'] + deleted_files)
  93. if modified_files:
  94. git.run(*['checkout', refactor_branch, '--'] + modified_files)
  95. # Commit changes. The temporary file is created with delete=False so that it
  96. # can be deleted manually after git has read it rather than automatically
  97. # when it is closed.
  98. with gclient_utils.temporary_file() as tmp_file:
  99. gclient_utils.FileWrite(
  100. tmp_file, FormatDescriptionOrComment(description, directory))
  101. git.run('commit', '-F', tmp_file)
  102. # Upload a CL.
  103. upload_args = ['-f', '-r', ','.join(reviewers)]
  104. if cq_dry_run:
  105. upload_args.append('--cq-dry-run')
  106. if not comment:
  107. upload_args.append('--send-mail')
  108. if enable_auto_submit:
  109. upload_args.append('--enable-auto-submit')
  110. print('Uploading CL for ' + directory + '.')
  111. cmd_upload(upload_args)
  112. if comment:
  113. changelist().AddComment(FormatDescriptionOrComment(comment, directory),
  114. publish=True)
  115. def GetFilesSplitByOwners(owners_database, files):
  116. """Returns a map of files split by OWNERS file.
  117. Returns:
  118. A map where keys are paths to directories containing an OWNERS file and
  119. values are lists of files sharing an OWNERS file.
  120. """
  121. files_split_by_owners = collections.defaultdict(list)
  122. for action, path in files:
  123. enclosing_dir = owners_database.enclosing_dir_with_owners(path)
  124. files_split_by_owners[enclosing_dir].append((action, path))
  125. return files_split_by_owners
  126. def PrintClInfo(cl_index, num_cls, directory, file_paths, description,
  127. reviewers):
  128. """Prints info about a CL.
  129. Args:
  130. cl_index: The index of this CL in the list of CLs to upload.
  131. num_cls: The total number of CLs that will be uploaded.
  132. directory: Path to the directory that contains the OWNERS file for which
  133. to upload a CL.
  134. file_paths: A list of files in this CL.
  135. description: The CL description.
  136. reviewers: A set of reviewers for this CL.
  137. """
  138. description_lines = FormatDescriptionOrComment(description,
  139. directory).splitlines()
  140. indented_description = '\n'.join([' ' + l for l in description_lines])
  141. print('CL {}/{}'.format(cl_index, num_cls))
  142. print('Path: {}'.format(directory))
  143. print('Reviewers: {}'.format(', '.join(reviewers)))
  144. print('\n' + indented_description + '\n')
  145. print('\n'.join(file_paths))
  146. print()
  147. def SplitCl(description_file, comment_file, changelist, cmd_upload, dry_run,
  148. cq_dry_run, enable_auto_submit, repository_root):
  149. """"Splits a branch into smaller branches and uploads CLs.
  150. Args:
  151. description_file: File containing the description of uploaded CLs.
  152. comment_file: File containing the comment of uploaded CLs.
  153. changelist: The Changelist class.
  154. cmd_upload: The function associated with the git cl upload command.
  155. dry_run: Whether this is a dry run (no branches or CLs created).
  156. cq_dry_run: If CL uploads should also do a cq dry run.
  157. enable_auto_submit: If CL uploads should also enable auto submit.
  158. Returns:
  159. 0 in case of success. 1 in case of error.
  160. """
  161. description = AddUploadedByGitClSplitToDescription(ReadFile(description_file))
  162. comment = ReadFile(comment_file) if comment_file else None
  163. try:
  164. EnsureInGitRepository()
  165. cl = changelist()
  166. upstream = cl.GetCommonAncestorWithUpstream()
  167. files = [
  168. (action.strip(), f)
  169. for action, f in scm.GIT.CaptureStatus(repository_root, upstream)
  170. ]
  171. if not files:
  172. print('Cannot split an empty CL.')
  173. return 1
  174. author = git.run('config', 'user.email').strip() or None
  175. refactor_branch = git.current_branch()
  176. assert refactor_branch, "Can't run from detached branch."
  177. refactor_branch_upstream = git.upstream(refactor_branch)
  178. assert refactor_branch_upstream, \
  179. "Branch %s must have an upstream." % refactor_branch
  180. owners_database = owners.Database(repository_root, open, os.path)
  181. owners_database.load_data_needed_for([f for _, f in files])
  182. files_split_by_owners = GetFilesSplitByOwners(owners_database, files)
  183. num_cls = len(files_split_by_owners)
  184. print('Will split current branch (' + refactor_branch + ') into ' +
  185. str(num_cls) + ' CLs.\n')
  186. if cq_dry_run and num_cls > CL_SPLIT_FORCE_LIMIT:
  187. print(
  188. 'This will generate "%r" CLs. This many CLs can potentially generate'
  189. ' too much load on the build infrastructure. Please email'
  190. ' infra-dev@chromium.org to ensure that this won\'t break anything.'
  191. ' The infra team reserves the right to cancel your jobs if they are'
  192. ' overloading the CQ.' % num_cls)
  193. answer = gclient_utils.AskForData('Proceed? (y/n):')
  194. if answer.lower() != 'y':
  195. return 0
  196. for cl_index, (directory, files) in \
  197. enumerate(files_split_by_owners.items(), 1):
  198. # Use '/' as a path separator in the branch name and the CL description
  199. # and comment.
  200. directory = directory.replace(os.path.sep, '/')
  201. file_paths = [f for _, f in files]
  202. reviewers = owners_database.reviewers_for(file_paths, author)
  203. if dry_run:
  204. PrintClInfo(cl_index, num_cls, directory, file_paths, description,
  205. reviewers)
  206. else:
  207. UploadCl(refactor_branch, refactor_branch_upstream, directory, files,
  208. description, comment, reviewers, changelist, cmd_upload,
  209. cq_dry_run, enable_auto_submit, repository_root)
  210. # Go back to the original branch.
  211. git.run('checkout', refactor_branch)
  212. except subprocess2.CalledProcessError as cpe:
  213. sys.stderr.write(cpe.stderr)
  214. return 1
  215. return 0