Index: depot_tools/revert.py |
=================================================================== |
--- depot_tools/revert.py (revision 0) |
+++ depot_tools/revert.py (revision 0) |
@@ -0,0 +1,284 @@ |
+#!/usr/bin/python |
+# Copyright (c) 2006-2009 The Chromium Authors. All rights reserved. |
+# Use of this source code is governed by a BSD-style license that can be |
+# found in the LICENSE file. |
+# |
+# Tool to quickly revert a change. |
+ |
+import exceptions |
+import optparse |
+import os |
+import sys |
+import xml |
+ |
+import gcl |
+import gclient |
+ |
+class ModifiedFile(exceptions.Exception): |
+ pass |
+class NoModifiedFile(exceptions.Exception): |
+ pass |
+class NoBlameList(exceptions.Exception): |
+ pass |
+class OutsideOfCheckout(exceptions.Exception): |
+ pass |
+ |
+ |
+def getTexts(nodelist): |
+ """Return a list of texts in the children of a list of DOM nodes.""" |
+ rc = [] |
+ for node in nodelist: |
+ if node.nodeType == node.TEXT_NODE: |
+ rc.append(node.data) |
+ else: |
+ rc.extend(getTexts(node.childNodes)) |
+ return rc |
+ |
+ |
+def RunShellXML(command, print_output=False, keys=None): |
+ output = gcl.RunShell(command, print_output) |
+ try: |
+ dom = xml.dom.minidom.parseString(output) |
+ if not keys: |
+ return dom |
+ result = {} |
+ for key in keys: |
+ result[key] = getTexts(dom.getElementsByTagName(key)) |
+ except xml.parsers.expat.ExpatError: |
+ print "Failed to parse output:\n%s" % output |
+ raise |
+ return result |
+ |
+ |
+def UniqueFast(list): |
+ list = [item for item in set(list)] |
+ list.sort() |
+ return list |
+ |
+ |
+def GetRepoBase(): |
+ """Returns the repository base of the root local checkout.""" |
+ xml_data = RunShellXML(['svn', 'info', '.', '--xml'], keys=['root', 'url']) |
+ root = xml_data['root'][0] |
+ url = xml_data['url'][0] |
+ if not root or not url: |
+ raise exceptions.Exception("I'm confused by your checkout") |
+ if not url.startswith(root): |
+ raise exceptions.Exception("I'm confused by your checkout", url, root) |
+ return url[len(root):] + '/' |
+ |
+ |
+def Revert(revisions, force=False, commit=True, send_email=True, message=None, |
+ reviewers=None): |
+ """Reverts many revisions in one change list. |
+ |
+ If force is True, it will override local modifications. |
+ If commit is True, a commit is done after the revert. |
+ If send_mail is True, a review email is sent. |
+ If message is True, it is used as the change description. |
+ reviewers overrides the blames email addresses for review email.""" |
+ |
+ # Use the oldest revision as the primary revision. |
+ changename = "revert%d" % revisions[len(revisions)-1] |
+ if not force and os.path.exists(gcl.GetChangelistInfoFile(changename)): |
+ print "Error, change %s already exist." % changename |
+ return 1 |
+ |
+ # Move to the repository root and make the revision numbers sorted in |
+ # decreasing order. |
+ os.chdir(gcl.GetRepositoryRoot()) |
+ revisions.sort(reverse=True) |
+ revisions_string = ",".join([str(rev) for rev in revisions]) |
+ revisions_string_rev = ",".join([str(-rev) for rev in revisions]) |
+ |
+ repo_base = GetRepoBase() |
+ files = [] |
+ blames = [] |
+ # Get all the modified files by the revision. We'll use this list to optimize |
+ # the svn merge. |
+ for revision in revisions: |
+ log = RunShellXML(["svn", "log", "-r", str(revision), "-v", "--xml"], |
+ keys=['path', 'author']) |
+ for file in log['path']: |
+ # Remove the /trunk/src/ part. The + 1 is for the last slash. |
+ if not file.startswith(repo_base): |
+ raise OutsideOfCheckout(file) |
+ files.append(file[len(repo_base):]) |
+ blames.extend(log['author']) |
+ |
+ # On Windows, we need to fix the slashes once they got the url part removed. |
+ if sys.platform == 'win32': |
+ # On Windows, gcl expect the correct slashes. |
+ files = [file.replace('/', os.sep) for file in files] |
+ |
+ # Keep unique. |
+ files = UniqueFast(files) |
+ blames = UniqueFast(blames) |
+ if not reviewers: |
+ reviewers = blames |
+ else: |
+ reviewers = UniqueFast(reviewers) |
+ |
+ # Make sure there's something to revert. |
+ if not files: |
+ raise NoModifiedFile |
+ if not reviewers: |
+ raise NoBlameList |
+ |
+ if blames: |
+ print "Blaming %s\n" % ",".join(blames) |
+ if reviewers != blames: |
+ print "Emailing %s\n" % ",".join(reviewers) |
+ print "These files were modified in %s:" % revisions_string |
+ print "\n".join(files) |
+ print "" |
+ |
+ # Make sure these files are unmodified with svn status. |
+ status = gcl.RunShell(["svn", "status"] + files) |
+ if status: |
+ if force: |
+ # TODO(maruel): Use the tool to correctly revert '?' files. |
+ gcl.RunShell(["svn", "revert"] + files) |
+ else: |
+ raise ModifiedFile(status) |
+ # svn up on each of these files |
+ gcl.RunShell(["svn", "up"] + files) |
+ |
+ files_status = {} |
+ # Extract the first level subpaths. Subversion seems to degrade |
+ # exponentially w.r.t. repository size during merges. Working at the root |
+ # directory is too rough for svn due to the repository size. |
+ roots = UniqueFast([file.split(os.sep)[0] for file in files]) |
+ for root in roots: |
+ # Is it a subdirectory or a files? |
+ is_root_subdir = os.path.isdir(root) |
+ need_to_update = False |
+ if is_root_subdir: |
+ os.chdir(root) |
+ file_list = [] |
+ # List the file directly since it is faster when there is only one file. |
+ for file in files: |
+ if file.startswith(root): |
+ file_list.append(file[len(root)+1:]) |
+ if len(file_list) > 1: |
+ # Listing multiple files is not supported by svn merge. |
+ file_list = ['.'] |
+ need_to_update = True |
+ else: |
+ # Oops, root was in fact a file in the root directory. |
+ file_list = [root] |
+ root = "." |
+ |
+ print "Reverting %s in %s/" % (revisions_string, root) |
+ if need_to_update: |
+ # Make sure '.' revision is high enough otherwise merge will be |
+ # unhappy. |
+ retcode = gcl.RunShellWithReturnCode(['svn', 'up', '.', '-N'])[1] |
+ if retcode: |
+ print 'svn up . -N failed in %s/.' % root |
+ return retcode |
+ |
+ command = ["svn", "merge", "-c", revisions_string_rev] |
+ command.extend(file_list) |
+ (output, retcode) = gcl.RunShellWithReturnCode(command, print_output=True) |
+ if retcode: |
+ print "'%s' failed:" % command |
+ return retcode |
+ |
+ # Grab the status |
+ lines = output.split('\n') |
+ for line in lines: |
+ if line.startswith('---'): |
+ continue |
+ if line.startswith('Skipped'): |
+ print "" |
+ raise ModifiedFile(line[9:-1]) |
+ # Update the status. |
+ status = line[:5] + ' ' |
+ file = line[5:] |
+ if is_root_subdir: |
+ files_status[root + os.sep + file] = status |
+ else: |
+ files_status[file] = status |
+ |
+ if is_root_subdir: |
+ os.chdir('..') |
+ |
+ # Transform files_status from a dictionary to a list of tuple. |
+ files_status = [(files_status[file], file) for file in files] |
+ |
+ description = "Reverting %s." % revisions_string |
+ if message: |
+ description += "\n\n" |
+ description += message |
+ # Don't use gcl.Change() since it prompts the user for infos. |
+ change_info = gcl.ChangeInfo(name=changename, issue='', |
+ description=description, files=files_status) |
+ change_info.Save() |
+ |
+ upload_args = ['-r', ",".join(reviewers)] |
+ if send_email: |
+ upload_args.append('--send_mail') |
+ if commit: |
+ upload_args.append('--no_try') |
+ gcl.UploadCL(change_info, upload_args) |
+ |
+ retcode = 0 |
+ if commit: |
+ gcl.Commit(change_info, ['--force']) |
+ # TODO(maruel): gclient sync (to leave the local checkout in an usable |
+ # state) |
+ retcode = gclient.Main(["gclient.py", "sync"]) |
+ return retcode |
+ |
+ |
+def Main(argv): |
+ usage = ( |
+"""%prog [options] [revision numbers to revert] |
+Revert a set of revisions, send the review to Rietveld, sends a review email |
+and optionally commit the revert.""") |
+ |
+ parser = optparse.OptionParser(usage=usage) |
+ parser.add_option("-c", "--commit", default=False, action="store_true", |
+ help="Commits right away.") |
+ parser.add_option("-f", "--force", default=False, action="store_true", |
+ help="Forces the local modification even if a file is " |
+ "already modified locally.") |
+ parser.add_option("-n", "--no_email", default=False, action="store_true", |
+ help="Inhibits from sending a review email.") |
+ parser.add_option("-m", "--message", default=None, |
+ help="Additional change description message.") |
+ parser.add_option("-r", "--reviewers", action="append", |
+ help="Reviewers to send the email to. By default, the list " |
+ "of commiters is used.") |
+ options, args = parser.parse_args(argv) |
+ revisions = [] |
+ try: |
+ for item in args[1:]: |
+ revisions.append(int(item)) |
+ except ValueError: |
+ parser.error("You need to pass revision numbers.") |
+ if not revisions: |
+ parser.error("You need to pass revision numbers.") |
+ retcode = 1 |
+ try: |
+ if not os.path.exists(gcl.GetInfoDir()): |
+ os.mkdir(gcl.GetInfoDir()) |
+ retcode = Revert(revisions, options.force, options.commit, |
+ not options.no_email, options.message, options.reviewers) |
+ except NoBlameList: |
+ print "Error: no one to blame." |
+ except NoModifiedFile: |
+ print "Error: no files to revert." |
+ except ModifiedFile, e: |
+ print "You need to revert these files since they were already modified:" |
+ print "".join(e.args) |
+ print "You can use the --force flag to revert the files." |
+ except OutsideOfCheckout, e: |
+ print "Your repository doesn't contain ", str(e) |
+ |
+ return retcode |
+ |
+ |
+if __name__ == "__main__": |
+ sys.exit(Main(sys.argv)) |
Property changes on: depot_tools\revert.py |
___________________________________________________________________ |
Added: svn:executable |
+ * |
Added: svn:eol-style |
+ LF |