split_cl.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. #!/usr/bin/env python3
  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 scm
  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 EnsureInGitRepository():
  23. """Throws an exception if the current directory is not a git repository."""
  24. git.run('rev-parse')
  25. def CreateBranchForDirectory(prefix, directory, upstream):
  26. """Creates a branch named |prefix| + "_" + |directory| + "_split".
  27. Return false if the branch already exists. |upstream| is used as upstream for
  28. the created branch.
  29. """
  30. existing_branches = set(git.branches(use_limit = False))
  31. branch_name = prefix + '_' + directory + '_split'
  32. if branch_name in existing_branches:
  33. return False
  34. git.run('checkout', '-t', upstream, '-b', branch_name)
  35. return True
  36. def FormatDescriptionOrComment(txt, directory):
  37. """Replaces $directory with |directory| in |txt|."""
  38. return txt.replace('$directory', '/' + directory)
  39. def AddUploadedByGitClSplitToDescription(description):
  40. """Adds a 'This CL was uploaded by git cl split.' line to |description|.
  41. The line is added before footers, or at the end of |description| if it has no
  42. footers.
  43. """
  44. split_footers = git_footers.split_footers(description)
  45. lines = split_footers[0]
  46. if lines[-1] and not lines[-1].isspace():
  47. lines = lines + ['']
  48. lines = lines + ['This CL was uploaded by git cl split.']
  49. if split_footers[1]:
  50. lines += [''] + split_footers[1]
  51. return '\n'.join(lines)
  52. def UploadCl(refactor_branch, refactor_branch_upstream, directory, files,
  53. description, comment, reviewers, changelist, cmd_upload,
  54. cq_dry_run, enable_auto_submit, repository_root):
  55. """Uploads a CL with all changes to |files| in |refactor_branch|.
  56. Args:
  57. refactor_branch: Name of the branch that contains the changes to upload.
  58. refactor_branch_upstream: Name of the upstream of |refactor_branch|.
  59. directory: Path to the directory that contains the OWNERS file for which
  60. to upload a CL.
  61. files: List of AffectedFile instances to include in the uploaded CL.
  62. description: Description of the uploaded CL.
  63. comment: Comment to post on the uploaded CL.
  64. reviewers: A set of reviewers for the CL.
  65. changelist: The Changelist class.
  66. cmd_upload: The function associated with the git cl upload command.
  67. cq_dry_run: If CL uploads should also do a cq dry run.
  68. enable_auto_submit: If CL uploads should also enable auto submit.
  69. """
  70. # Create a branch.
  71. if not CreateBranchForDirectory(
  72. refactor_branch, directory, refactor_branch_upstream):
  73. print('Skipping ' + directory + ' for which a branch already exists.')
  74. return
  75. # Checkout all changes to files in |files|.
  76. deleted_files = []
  77. modified_files = []
  78. for action, f in files:
  79. abspath = os.path.abspath(os.path.join(repository_root, f))
  80. if action == 'D':
  81. deleted_files.append(abspath)
  82. else:
  83. modified_files.append(abspath)
  84. if deleted_files:
  85. git.run(*['rm'] + deleted_files)
  86. if modified_files:
  87. git.run(*['checkout', refactor_branch, '--'] + modified_files)
  88. # Commit changes. The temporary file is created with delete=False so that it
  89. # can be deleted manually after git has read it rather than automatically
  90. # when it is closed.
  91. with gclient_utils.temporary_file() as tmp_file:
  92. gclient_utils.FileWrite(
  93. tmp_file, FormatDescriptionOrComment(description, directory))
  94. git.run('commit', '-F', tmp_file)
  95. # Upload a CL.
  96. upload_args = ['-f']
  97. if reviewers:
  98. upload_args.extend(['-r', ','.join(reviewers)])
  99. if cq_dry_run:
  100. upload_args.append('--cq-dry-run')
  101. if not comment:
  102. upload_args.append('--send-mail')
  103. if enable_auto_submit:
  104. upload_args.append('--enable-auto-submit')
  105. print('Uploading CL for ' + directory + '...')
  106. ret = cmd_upload(upload_args)
  107. if ret != 0:
  108. print('Uploading failed for ' + directory + '.')
  109. print('Note: git cl split has built-in resume capabilities.')
  110. print('Delete ' + git.current_branch() +
  111. ' then run git cl split again to resume uploading.')
  112. if comment:
  113. changelist().AddComment(FormatDescriptionOrComment(comment, directory),
  114. publish=True)
  115. def GetFilesSplitByOwners(files, max_depth):
  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 = {}
  122. for action, path in files:
  123. # normpath() is important to normalize separators here, in prepration for
  124. # str.split() before. It would be nicer to use something like pathlib here
  125. # but alas...
  126. dir_with_owners = os.path.normpath(os.path.dirname(path))
  127. if max_depth >= 1:
  128. dir_with_owners = os.path.join(
  129. *dir_with_owners.split(os.path.sep)[:max_depth])
  130. # Find the closest parent directory with an OWNERS file.
  131. while (dir_with_owners not in files_split_by_owners
  132. and not os.path.isfile(os.path.join(dir_with_owners, 'OWNERS'))):
  133. dir_with_owners = os.path.dirname(dir_with_owners)
  134. files_split_by_owners.setdefault(dir_with_owners, []).append((action, path))
  135. return files_split_by_owners
  136. def PrintClInfo(cl_index, num_cls, directory, file_paths, description,
  137. reviewers):
  138. """Prints info about a CL.
  139. Args:
  140. cl_index: The index of this CL in the list of CLs to upload.
  141. num_cls: The total number of CLs that will be uploaded.
  142. directory: Path to the directory that contains the OWNERS file for which
  143. to upload a CL.
  144. file_paths: A list of files in this CL.
  145. description: The CL description.
  146. reviewers: A set of reviewers for this CL.
  147. """
  148. description_lines = FormatDescriptionOrComment(description,
  149. directory).splitlines()
  150. indented_description = '\n'.join([' ' + l for l in description_lines])
  151. print('CL {}/{}'.format(cl_index, num_cls))
  152. print('Path: {}'.format(directory))
  153. print('Reviewers: {}'.format(', '.join(reviewers)))
  154. print('\n' + indented_description + '\n')
  155. print('\n'.join(file_paths))
  156. print()
  157. def SplitCl(description_file, comment_file, changelist, cmd_upload, dry_run,
  158. cq_dry_run, enable_auto_submit, max_depth, repository_root):
  159. """"Splits a branch into smaller branches and uploads CLs.
  160. Args:
  161. description_file: File containing the description of uploaded CLs.
  162. comment_file: File containing the comment of uploaded CLs.
  163. changelist: The Changelist class.
  164. cmd_upload: The function associated with the git cl upload command.
  165. dry_run: Whether this is a dry run (no branches or CLs created).
  166. cq_dry_run: If CL uploads should also do a cq dry run.
  167. enable_auto_submit: If CL uploads should also enable auto submit.
  168. max_depth: The maximum directory depth to search for OWNERS files. A value
  169. less than 1 means no limit.
  170. Returns:
  171. 0 in case of success. 1 in case of error.
  172. """
  173. description = AddUploadedByGitClSplitToDescription(
  174. gclient_utils.FileRead(description_file))
  175. comment = gclient_utils.FileRead(comment_file) if comment_file else None
  176. try:
  177. EnsureInGitRepository()
  178. cl = changelist()
  179. upstream = cl.GetCommonAncestorWithUpstream()
  180. files = [
  181. (action.strip(), f)
  182. for action, f in scm.GIT.CaptureStatus(repository_root, upstream)
  183. ]
  184. if not files:
  185. print('Cannot split an empty CL.')
  186. return 1
  187. author = git.run('config', 'user.email').strip() or None
  188. refactor_branch = git.current_branch()
  189. assert refactor_branch, "Can't run from detached branch."
  190. refactor_branch_upstream = git.upstream(refactor_branch)
  191. assert refactor_branch_upstream, \
  192. "Branch %s must have an upstream." % refactor_branch
  193. files_split_by_owners = GetFilesSplitByOwners(files, max_depth)
  194. num_cls = len(files_split_by_owners)
  195. print('Will split current branch (' + refactor_branch + ') into ' +
  196. str(num_cls) + ' CLs.\n')
  197. if cq_dry_run and num_cls > CL_SPLIT_FORCE_LIMIT:
  198. print(
  199. 'This will generate "%r" CLs. This many CLs can potentially generate'
  200. ' too much load on the build infrastructure. Please email'
  201. ' infra-dev@chromium.org to ensure that this won\'t break anything.'
  202. ' The infra team reserves the right to cancel your jobs if they are'
  203. ' overloading the CQ.' % num_cls)
  204. answer = gclient_utils.AskForData('Proceed? (y/n):')
  205. if answer.lower() != 'y':
  206. return 0
  207. # Verify that the description contains a bug link. Examples:
  208. # Bug: 123
  209. # Bug: chromium:456
  210. bug_pattern = re.compile(r"^Bug:\s*(?:[a-zA-Z]+:)?[0-9]+", re.MULTILINE)
  211. matches = re.findall(bug_pattern, description)
  212. answer = 'y'
  213. if not matches:
  214. answer = gclient_utils.AskForData(
  215. 'Description does not include a bug link. Proceed? (y/n):')
  216. if answer.lower() != 'y':
  217. return 0
  218. for cl_index, (directory, files) in \
  219. enumerate(files_split_by_owners.items(), 1):
  220. # Use '/' as a path separator in the branch name and the CL description
  221. # and comment.
  222. directory = directory.replace(os.path.sep, '/')
  223. file_paths = [f for _, f in files]
  224. reviewers = cl.owners_client.SuggestOwners(
  225. file_paths, exclude=[author, cl.owners_client.EVERYONE])
  226. if dry_run:
  227. PrintClInfo(cl_index, num_cls, directory, file_paths, description,
  228. reviewers)
  229. else:
  230. UploadCl(refactor_branch, refactor_branch_upstream, directory, files,
  231. description, comment, reviewers, changelist, cmd_upload,
  232. cq_dry_run, enable_auto_submit, repository_root)
  233. # Go back to the original branch.
  234. git.run('checkout', refactor_branch)
  235. except subprocess2.CalledProcessError as cpe:
  236. sys.stderr.write(cpe.stderr)
  237. return 1
  238. return 0