| 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 |