split_cl.py 9.0 KB

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