OLD | NEW |
| (Empty) |
1 #!/usr/bin/python | |
2 # Copyright (c) 2006-2009 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 # Tool to quickly revert a change. | |
7 | |
8 import exceptions | |
9 import optparse | |
10 import os | |
11 import sys | |
12 import xml | |
13 | |
14 import gcl | |
15 import gclient | |
16 import gclient_scm | |
17 import gclient_utils | |
18 | |
19 class ModifiedFile(exceptions.Exception): | |
20 pass | |
21 class NoModifiedFile(exceptions.Exception): | |
22 pass | |
23 class NoBlameList(exceptions.Exception): | |
24 pass | |
25 class OutsideOfCheckout(exceptions.Exception): | |
26 pass | |
27 | |
28 | |
29 def UniqueFast(list): | |
30 list = [item for item in set(list)] | |
31 list.sort() | |
32 return list | |
33 | |
34 | |
35 def GetRepoBase(): | |
36 """Returns the repository base of the root local checkout.""" | |
37 info = gclient_scm.CaptureSVNInfo('.') | |
38 root = info['Repository Root'] | |
39 url = info['URL'] | |
40 if not root or not url: | |
41 raise exceptions.Exception("I'm confused by your checkout") | |
42 if not url.startswith(root): | |
43 raise exceptions.Exception("I'm confused by your checkout", url, root) | |
44 return url[len(root):] + '/' | |
45 | |
46 | |
47 def CaptureSVNLog(args): | |
48 command = ['log', '--xml'] | |
49 if args: | |
50 command += args | |
51 output = gclient_scm.CaptureSVN(command) | |
52 dom = gclient_utils.ParseXML(output) | |
53 entries = [] | |
54 if dom: | |
55 # /log/logentry/ | |
56 # @revision | |
57 # author|date | |
58 # paths/ | |
59 # path (@kind&@action) | |
60 for node in dom.getElementsByTagName('logentry'): | |
61 paths = [] | |
62 for path in node.getElementsByTagName('path'): | |
63 item = { | |
64 'kind': path.getAttribute('kind'), | |
65 'action': path.getAttribute('action'), | |
66 'path': path.firstChild.nodeValue, | |
67 } | |
68 paths.append(item) | |
69 entry = { | |
70 'revision': int(node.getAttribute('revision')), | |
71 'author': gclient_utils.GetNamedNodeText(node, 'author'), | |
72 'date': gclient_utils.GetNamedNodeText(node, 'date'), | |
73 'paths': paths, | |
74 } | |
75 entries.append(entry) | |
76 return entries | |
77 | |
78 | |
79 def Revert(revisions, force=False, commit=True, send_email=True, message=None, | |
80 reviewers=None): | |
81 """Reverts many revisions in one change list. | |
82 | |
83 If force is True, it will override local modifications. | |
84 If commit is True, a commit is done after the revert. | |
85 If send_mail is True, a review email is sent. | |
86 If message is True, it is used as the change description. | |
87 reviewers overrides the blames email addresses for review email.""" | |
88 | |
89 # Use the oldest revision as the primary revision. | |
90 changename = "revert%d" % revisions[len(revisions)-1] | |
91 if not force and os.path.exists(gcl.GetChangelistInfoFile(changename)): | |
92 print "Error, change %s already exist." % changename | |
93 return 1 | |
94 | |
95 # Move to the repository root and make the revision numbers sorted in | |
96 # decreasing order. | |
97 local_root = gcl.GetRepositoryRoot() | |
98 os.chdir(local_root) | |
99 revisions.sort(reverse=True) | |
100 revisions_string = ",".join([str(rev) for rev in revisions]) | |
101 revisions_string_rev = ",".join([str(-rev) for rev in revisions]) | |
102 | |
103 # Get all the modified files by the revision. We'll use this list to optimize | |
104 # the svn merge. | |
105 logs = [] | |
106 for revision in revisions: | |
107 logs.extend(CaptureSVNLog(["-r", str(revision), "-v"])) | |
108 | |
109 files = [] | |
110 blames = [] | |
111 repo_base = GetRepoBase() | |
112 for log in logs: | |
113 for file in log['paths']: | |
114 file_name = file['path'] | |
115 # Remove the /trunk/src/ part. The + 1 is for the last slash. | |
116 if not file_name.startswith(repo_base): | |
117 raise OutsideOfCheckout(file_name) | |
118 files.append(file_name[len(repo_base):]) | |
119 blames.append(log['author']) | |
120 | |
121 # On Windows, we need to fix the slashes once they got the url part removed. | |
122 if sys.platform == 'win32': | |
123 # On Windows, gcl expect the correct slashes. | |
124 files = [file.replace('/', os.sep) for file in files] | |
125 | |
126 # Keep unique. | |
127 files = UniqueFast(files) | |
128 blames = UniqueFast(blames) | |
129 if not reviewers: | |
130 reviewers = blames | |
131 else: | |
132 reviewers = UniqueFast(reviewers) | |
133 | |
134 # Make sure there's something to revert. | |
135 if not files: | |
136 raise NoModifiedFile | |
137 if not reviewers: | |
138 raise NoBlameList | |
139 | |
140 if blames: | |
141 print "Blaming %s\n" % ",".join(blames) | |
142 if reviewers != blames: | |
143 print "Emailing %s\n" % ",".join(reviewers) | |
144 print "These files were modified in %s:" % revisions_string | |
145 print "\n".join(files) | |
146 print "" | |
147 | |
148 # Make sure these files are unmodified with svn status. | |
149 status = gclient_scm.scm.SVN.CaptureStatus(files) | |
150 if status: | |
151 if force: | |
152 # TODO(maruel): Use the tool to correctly revert '?' files. | |
153 gcl.RunShell(["svn", "revert"] + files) | |
154 else: | |
155 raise ModifiedFile(status) | |
156 # svn up on each of these files | |
157 gcl.RunShell(["svn", "up"] + files) | |
158 | |
159 files_status = {} | |
160 # Extract the first level subpaths. Subversion seems to degrade | |
161 # exponentially w.r.t. repository size during merges. Working at the root | |
162 # directory is too rough for svn due to the repository size. | |
163 roots = UniqueFast([file.split(os.sep)[0] for file in files]) | |
164 for root in roots: | |
165 # Is it a subdirectory or a files? | |
166 is_root_subdir = os.path.isdir(root) | |
167 need_to_update = False | |
168 if is_root_subdir: | |
169 os.chdir(root) | |
170 file_list = [] | |
171 # List the file directly since it is faster when there is only one file. | |
172 for file in files: | |
173 if file.startswith(root): | |
174 file_list.append(file[len(root)+1:]) | |
175 if len(file_list) > 1: | |
176 # Listing multiple files is not supported by svn merge. | |
177 file_list = ['.'] | |
178 need_to_update = True | |
179 else: | |
180 # Oops, root was in fact a file in the root directory. | |
181 file_list = [root] | |
182 root = "." | |
183 | |
184 print "Reverting %s in %s/" % (revisions_string, root) | |
185 if need_to_update: | |
186 # Make sure '.' revision is high enough otherwise merge will be | |
187 # unhappy. | |
188 retcode = gcl.RunShellWithReturnCode(['svn', 'up', '.', '-N'])[1] | |
189 if retcode: | |
190 print 'svn up . -N failed in %s/.' % root | |
191 return retcode | |
192 | |
193 command = ["svn", "merge", "-c", revisions_string_rev] | |
194 command.extend(file_list) | |
195 (output, retcode) = gcl.RunShellWithReturnCode(command, print_output=True) | |
196 if retcode: | |
197 print "'%s' failed:" % command | |
198 return retcode | |
199 | |
200 # Grab the status | |
201 lines = output.split('\n') | |
202 for line in lines: | |
203 if line.startswith('---'): | |
204 continue | |
205 if line.startswith('Skipped'): | |
206 print "" | |
207 raise ModifiedFile(line[9:-1]) | |
208 # Update the status. | |
209 status = line[:5] + ' ' | |
210 file = line[5:] | |
211 if is_root_subdir: | |
212 files_status[root + os.sep + file] = status | |
213 else: | |
214 files_status[file] = status | |
215 | |
216 if is_root_subdir: | |
217 os.chdir('..') | |
218 | |
219 # Transform files_status from a dictionary to a list of tuple. | |
220 files_status = [(files_status[file], file) for file in files] | |
221 | |
222 description = "Reverting %s." % revisions_string | |
223 if message: | |
224 description += "\n\n" | |
225 description += message | |
226 # Don't use gcl.Change() since it prompts the user for infos. | |
227 change_info = gcl.ChangeInfo(changename, 0, 0, description, files_status, | |
228 local_root) | |
229 change_info.Save() | |
230 | |
231 upload_args = ['--no_presubmit', '-r', ",".join(reviewers)] | |
232 if send_email: | |
233 upload_args.append('--send_mail') | |
234 if commit: | |
235 upload_args.append('--no_try') | |
236 gcl.UploadCL(change_info, upload_args) | |
237 | |
238 retcode = 0 | |
239 if commit: | |
240 gcl.Commit(change_info, ['--no_presubmit', '--force']) | |
241 # TODO(maruel): gclient sync (to leave the local checkout in an usable | |
242 # state) | |
243 retcode = gclient.Main(["gclient.py", "sync"]) | |
244 return retcode | |
245 | |
246 | |
247 def Main(argv): | |
248 usage = ( | |
249 """%prog [options] [revision numbers to revert] | |
250 Revert a set of revisions, send the review to Rietveld, sends a review email | |
251 and optionally commit the revert.""") | |
252 | |
253 parser = optparse.OptionParser(usage=usage) | |
254 parser.add_option("-c", "--commit", default=False, action="store_true", | |
255 help="Commits right away.") | |
256 parser.add_option("-f", "--force", default=False, action="store_true", | |
257 help="Forces the local modification even if a file is " | |
258 "already modified locally.") | |
259 parser.add_option("-n", "--no_email", default=False, action="store_true", | |
260 help="Inhibits from sending a review email.") | |
261 parser.add_option("-m", "--message", default=None, | |
262 help="Additional change description message.") | |
263 parser.add_option("-r", "--reviewers", action="append", | |
264 help="Reviewers to send the email to. By default, the list " | |
265 "of commiters is used.") | |
266 if len(argv) < 2: | |
267 parser.print_help() | |
268 return 1; | |
269 | |
270 options, args = parser.parse_args(argv) | |
271 revisions = [] | |
272 try: | |
273 for item in args[1:]: | |
274 revisions.append(int(item)) | |
275 except ValueError: | |
276 parser.error("You need to pass revision numbers.") | |
277 if not revisions: | |
278 parser.error("You need to pass revision numbers.") | |
279 retcode = 1 | |
280 try: | |
281 if not os.path.exists(gcl.GetInfoDir()): | |
282 os.mkdir(gcl.GetInfoDir()) | |
283 retcode = Revert(revisions, options.force, options.commit, | |
284 not options.no_email, options.message, options.reviewers) | |
285 except NoBlameList: | |
286 print "Error: no one to blame." | |
287 except NoModifiedFile: | |
288 print "Error: no files to revert." | |
289 except ModifiedFile, e: | |
290 print "You need to revert these files since they were already modified:" | |
291 print "".join(e.args) | |
292 print "You can use the --force flag to revert the files." | |
293 except OutsideOfCheckout, e: | |
294 print "Your repository doesn't contain ", str(e) | |
295 | |
296 return retcode | |
297 | |
298 | |
299 if __name__ == "__main__": | |
300 sys.exit(Main(sys.argv)) | |
OLD | NEW |