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