You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			301 lines
		
	
	
		
			9.4 KiB
		
	
	
	
		
			Python
		
	
			
		
		
	
	
			301 lines
		
	
	
		
			9.4 KiB
		
	
	
	
		
			Python
		
	
#!/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
 | 
						|
import gclient_scm
 | 
						|
import gclient_utils
 | 
						|
 | 
						|
class ModifiedFile(exceptions.Exception):
 | 
						|
  pass
 | 
						|
class NoModifiedFile(exceptions.Exception):
 | 
						|
  pass
 | 
						|
class NoBlameList(exceptions.Exception):
 | 
						|
  pass
 | 
						|
class OutsideOfCheckout(exceptions.Exception):
 | 
						|
  pass
 | 
						|
 | 
						|
 | 
						|
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."""
 | 
						|
  info = gclient_scm.CaptureSVNInfo('.')
 | 
						|
  root = info['Repository Root']
 | 
						|
  url = info['URL']
 | 
						|
  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 CaptureSVNLog(args):
 | 
						|
  command = ['log', '--xml']
 | 
						|
  if args:
 | 
						|
    command += args
 | 
						|
  output = gclient_scm.CaptureSVN(command)
 | 
						|
  dom = gclient_utils.ParseXML(output)
 | 
						|
  entries = []
 | 
						|
  if dom:
 | 
						|
    # /log/logentry/
 | 
						|
    #   @revision
 | 
						|
    #   author|date
 | 
						|
    #   paths/
 | 
						|
    #     path (@kind&@action)
 | 
						|
    for node in dom.getElementsByTagName('logentry'):
 | 
						|
      paths = []
 | 
						|
      for path in node.getElementsByTagName('path'):
 | 
						|
        item = {
 | 
						|
          'kind': path.getAttribute('kind'),
 | 
						|
          'action': path.getAttribute('action'),
 | 
						|
          'path': path.firstChild.nodeValue,
 | 
						|
        }
 | 
						|
        paths.append(item)
 | 
						|
      entry = {
 | 
						|
        'revision': int(node.getAttribute('revision')),
 | 
						|
        'author': gclient_utils.GetNamedNodeText(node, 'author'),
 | 
						|
        'date': gclient_utils.GetNamedNodeText(node, 'date'),
 | 
						|
        'paths': paths,
 | 
						|
      }
 | 
						|
      entries.append(entry)
 | 
						|
  return entries
 | 
						|
 | 
						|
 | 
						|
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.
 | 
						|
  local_root = gcl.GetRepositoryRoot()
 | 
						|
  os.chdir(local_root)
 | 
						|
  revisions.sort(reverse=True)
 | 
						|
  revisions_string = ",".join([str(rev) for rev in revisions])
 | 
						|
  revisions_string_rev = ",".join([str(-rev) for rev in revisions])
 | 
						|
 | 
						|
  # Get all the modified files by the revision. We'll use this list to optimize
 | 
						|
  # the svn merge.
 | 
						|
  logs = []
 | 
						|
  for revision in revisions:
 | 
						|
    logs.extend(CaptureSVNLog(["-r", str(revision), "-v"]))
 | 
						|
 | 
						|
  files = []
 | 
						|
  blames = []
 | 
						|
  repo_base = GetRepoBase()
 | 
						|
  for log in logs:
 | 
						|
    for file in log['paths']:
 | 
						|
      file_name = file['path']
 | 
						|
      # Remove the /trunk/src/ part. The + 1 is for the last slash.
 | 
						|
      if not file_name.startswith(repo_base):
 | 
						|
        raise OutsideOfCheckout(file_name)
 | 
						|
      files.append(file_name[len(repo_base):])
 | 
						|
    blames.append(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 = gclient_scm.scm.SVN.CaptureStatus(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(changename, 0, 0, description, files_status,
 | 
						|
                               local_root)
 | 
						|
  change_info.Save()
 | 
						|
 | 
						|
  upload_args = ['--no_presubmit', '-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, ['--no_presubmit', '--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.")
 | 
						|
  if len(argv) < 2:
 | 
						|
    parser.print_help()
 | 
						|
    return 1;
 | 
						|
 | 
						|
  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))
 |