blob: 05c682e1c79f846339d31755cb211dc01d5b78eb [file] [log] [blame]
Francois Dorayd42c6812017-05-30 15:10:20 -04001#!/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
6"""Splits a branch into smaller branches and uploads CLs."""
7
8import collections
9import os
10import re
11import subprocess2
12import sys
13import tempfile
14
15import git_footers
16import owners
17import owners_finder
18
19import git_common as git
20
21
22def 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
28
29def EnsureInGitRepository():
30 """Throws an exception if the current directory is not a git repository."""
31 git.run('rev-parse')
32
33
34def CreateBranchForDirectory(prefix, directory, upstream):
35 """Creates a branch named |prefix| + "_" + |directory| + "_split".
36
37 Return false if the branch already exists. |upstream| is used as upstream for
38 the created branch.
39 """
40 existing_branches = set(git.branches(use_limit = False))
41 branch_name = prefix + '_' + directory + '_split'
42 if branch_name in existing_branches:
43 return False
44 git.run('checkout', '-t', upstream, '-b', branch_name)
45 return True
46
47
48def FormatDescriptionOrComment(txt, directory):
49 """Replaces $directory with |directory| in |txt|."""
50 return txt.replace('$directory', '/' + directory)
51
52
53def AddUploadedByGitClSplitToDescription(description):
54 """Adds a 'This CL was uploaded by git cl split.' line to |description|.
55
56 The line is added before footers, or at the end of |description| if it has no
57 footers.
58 """
59 split_footers = git_footers.split_footers(description)
60 lines = split_footers[0]
61 if not lines[-1] or lines[-1].isspace():
62 lines = lines + ['']
63 lines = lines + ['This CL was uploaded by git cl split.']
64 if split_footers[1]:
65 lines += [''] + split_footers[1]
66 return '\n'.join(lines)
67
68
69def UploadCl(refactor_branch, refactor_branch_upstream, directory, files,
Stephen Martiniscb326682018-08-29 21:06:30 +000070 description, comment, reviewers, changelist, cmd_upload,
71 cq_dry_run):
Francois Dorayd42c6812017-05-30 15:10:20 -040072 """Uploads a CL with all changes to |files| in |refactor_branch|.
73
74 Args:
75 refactor_branch: Name of the branch that contains the changes to upload.
76 refactor_branch_upstream: Name of the upstream of |refactor_branch|.
77 directory: Path to the directory that contains the OWNERS file for which
78 to upload a CL.
79 files: List of AffectedFile instances to include in the uploaded CL.
Francois Dorayd42c6812017-05-30 15:10:20 -040080 description: Description of the uploaded CL.
81 comment: Comment to post on the uploaded CL.
Chris Watkinsba28e462017-12-13 11:22:17 +110082 reviewers: A set of reviewers for the CL.
Francois Dorayd42c6812017-05-30 15:10:20 -040083 changelist: The Changelist class.
84 cmd_upload: The function associated with the git cl upload command.
Stephen Martiniscb326682018-08-29 21:06:30 +000085 cq_dry_run: If CL uploads should also do a cq dry run.
Francois Dorayd42c6812017-05-30 15:10:20 -040086 """
Francois Dorayd42c6812017-05-30 15:10:20 -040087 # Create a branch.
88 if not CreateBranchForDirectory(
89 refactor_branch, directory, refactor_branch_upstream):
90 print 'Skipping ' + directory + ' for which a branch already exists.'
91 return
92
93 # Checkout all changes to files in |files|.
94 deleted_files = [f.AbsoluteLocalPath() for f in files if f.Action() == 'D']
95 if deleted_files:
96 git.run(*['rm'] + deleted_files)
97 modified_files = [f.AbsoluteLocalPath() for f in files if f.Action() != 'D']
98 if modified_files:
99 git.run(*['checkout', refactor_branch, '--'] + modified_files)
100
101 # Commit changes. The temporary file is created with delete=False so that it
102 # can be deleted manually after git has read it rather than automatically
103 # when it is closed.
104 with tempfile.NamedTemporaryFile(delete=False) as tmp_file:
105 tmp_file.write(FormatDescriptionOrComment(description, directory))
106 # Close the file to let git open it at the next line.
107 tmp_file.close()
108 git.run('commit', '-F', tmp_file.name)
109 os.remove(tmp_file.name)
110
111 # Upload a CL.
Stephen Martiniscb326682018-08-29 21:06:30 +0000112 upload_args = ['-f', '-r', ','.join(reviewers)]
113 if cq_dry_run:
114 upload_args.append('--cq-dry-run')
Francois Dorayd42c6812017-05-30 15:10:20 -0400115 if not comment:
Aaron Gablee5adf612017-07-14 10:43:58 -0700116 upload_args.append('--send-mail')
Francois Dorayd42c6812017-05-30 15:10:20 -0400117 print 'Uploading CL for ' + directory + '.'
118 cmd_upload(upload_args)
119 if comment:
Aaron Gablee5adf612017-07-14 10:43:58 -0700120 changelist().AddComment(FormatDescriptionOrComment(comment, directory),
121 publish=True)
Francois Dorayd42c6812017-05-30 15:10:20 -0400122
123
124def GetFilesSplitByOwners(owners_database, files):
125 """Returns a map of files split by OWNERS file.
126
127 Returns:
128 A map where keys are paths to directories containing an OWNERS file and
129 values are lists of files sharing an OWNERS file.
130 """
131 files_split_by_owners = collections.defaultdict(list)
132 for f in files:
133 files_split_by_owners[owners_database.enclosing_dir_with_owners(
134 f.LocalPath())].append(f)
135 return files_split_by_owners
136
137
Chris Watkinsba28e462017-12-13 11:22:17 +1100138def PrintClInfo(cl_index, num_cls, directory, file_paths, description,
139 reviewers):
140 """Prints info about a CL.
141
142 Args:
143 cl_index: The index of this CL in the list of CLs to upload.
144 num_cls: The total number of CLs that will be uploaded.
145 directory: Path to the directory that contains the OWNERS file for which
146 to upload a CL.
147 file_paths: A list of files in this CL.
148 description: The CL description.
149 reviewers: A set of reviewers for this CL.
150 """
151 description_lines = FormatDescriptionOrComment(description,
152 directory).splitlines()
153 indented_description = '\n'.join([' ' + l for l in description_lines])
154
155 print 'CL {}/{}'.format(cl_index, num_cls)
156 print 'Path: {}'.format(directory)
157 print 'Reviewers: {}'.format(', '.join(reviewers))
158 print '\n' + indented_description + '\n'
159 print '\n'.join(file_paths)
160 print
161
162
Stephen Martiniscb326682018-08-29 21:06:30 +0000163def SplitCl(description_file, comment_file, changelist, cmd_upload, dry_run,
164 cq_dry_run):
Francois Dorayd42c6812017-05-30 15:10:20 -0400165 """"Splits a branch into smaller branches and uploads CLs.
166
167 Args:
168 description_file: File containing the description of uploaded CLs.
169 comment_file: File containing the comment of uploaded CLs.
170 changelist: The Changelist class.
171 cmd_upload: The function associated with the git cl upload command.
Chris Watkinsba28e462017-12-13 11:22:17 +1100172 dry_run: Whether this is a dry run (no branches or CLs created).
Stephen Martiniscb326682018-08-29 21:06:30 +0000173 cq_dry_run: If CL uploads should also do a cq dry run.
Francois Dorayd42c6812017-05-30 15:10:20 -0400174
175 Returns:
176 0 in case of success. 1 in case of error.
177 """
178 description = AddUploadedByGitClSplitToDescription(ReadFile(description_file))
179 comment = ReadFile(comment_file) if comment_file else None
180
181 try:
Chris Watkinsba28e462017-12-13 11:22:17 +1100182 EnsureInGitRepository()
Francois Dorayd42c6812017-05-30 15:10:20 -0400183
184 cl = changelist()
185 change = cl.GetChange(cl.GetCommonAncestorWithUpstream(), None)
186 files = change.AffectedFiles()
187
188 if not files:
189 print 'Cannot split an empty CL.'
190 return 1
191
192 author = git.run('config', 'user.email').strip() or None
193 refactor_branch = git.current_branch()
Gabriel Charette09baacd2017-11-09 13:30:41 -0500194 assert refactor_branch, "Can't run from detached branch."
Francois Dorayd42c6812017-05-30 15:10:20 -0400195 refactor_branch_upstream = git.upstream(refactor_branch)
Gabriel Charette09baacd2017-11-09 13:30:41 -0500196 assert refactor_branch_upstream, \
197 "Branch %s must have an upstream." % refactor_branch
Francois Dorayd42c6812017-05-30 15:10:20 -0400198
199 owners_database = owners.Database(change.RepositoryRoot(), file, os.path)
200 owners_database.load_data_needed_for([f.LocalPath() for f in files])
201
Chris Watkinsba28e462017-12-13 11:22:17 +1100202 files_split_by_owners = GetFilesSplitByOwners(owners_database, files)
Francois Dorayd42c6812017-05-30 15:10:20 -0400203
Chris Watkinsba28e462017-12-13 11:22:17 +1100204 num_cls = len(files_split_by_owners)
205 print('Will split current branch (' + refactor_branch + ') into ' +
206 str(num_cls) + ' CLs.\n')
Stephen Martiniscb326682018-08-29 21:06:30 +0000207 if cq_dry_ru and num_cls > CL_SPLIT_FORCE_LIMIT:
208 print (
209 'This will generate "%r" CLs. This many CLs can potentially generate'
210 ' too much load on the build infrastructure. Please email'
211 ' infra-dev@chromium.org to ensure that this won\'t break anything.'
212 ' The infra team reserves the right to cancel your jobs if they are'
213 ' overloading the CQ.') % num_cls
214 answer = raw_input('Proceed? (y/n):')
215 if answer.lower() != 'y':
216 return 0
Francois Dorayd42c6812017-05-30 15:10:20 -0400217
Chris Watkinsba28e462017-12-13 11:22:17 +1100218 for cl_index, (directory, files) in \
219 enumerate(files_split_by_owners.iteritems(), 1):
Francois Dorayd42c6812017-05-30 15:10:20 -0400220 # Use '/' as a path separator in the branch name and the CL description
221 # and comment.
222 directory = directory.replace(os.path.sep, '/')
Chris Watkinsba28e462017-12-13 11:22:17 +1100223 file_paths = [f.LocalPath() for f in files]
224 reviewers = owners_database.reviewers_for(file_paths, author)
225
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,
Stephen Martiniscb326682018-08-29 21:06:30 +0000231 description, comment, reviewers, changelist, cmd_upload,
232 cq_dry_run)
Francois Dorayd42c6812017-05-30 15:10:20 -0400233
234 # Go back to the original branch.
235 git.run('checkout', refactor_branch)
236
237 except subprocess2.CalledProcessError as cpe:
238 sys.stderr.write(cpe.stderr)
239 return 1
240 return 0