| #!/usr/bin/env python |
| # Copyright (c) 2012 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. |
| |
| import datetime |
| import optparse |
| import os |
| import re |
| import sys |
| import urlparse |
| |
| |
| import gclient_utils |
| import subprocess2 |
| |
| USAGE = """ |
| WARNING: Please use this tool in an empty directory |
| (or at least one that you don't mind clobbering.) |
| |
| REQUIRES: SVN 1.5+ |
| NOTE: NO NEED TO CHECKOUT ANYTHING IN ADVANCE OF USING THIS TOOL. |
| Valid parameters: |
| |
| [Merge from trunk to branch] |
| --merge <revision> --branch <branch_num> |
| Example: %(app)s --merge 12345 --branch 187 |
| |
| [Merge from trunk to local copy] |
| --merge <revision> --local |
| Example: %(app)s --merge 12345 --local |
| |
| [Merge from branch to branch] |
| --merge <revision> --sbranch <branch_num> --branch <branch_num> |
| Example: %(app)s --merge 12345 --sbranch 248 --branch 249 |
| |
| [Revert from trunk] |
| --revert <revision> |
| Example: %(app)s --revert 12345 |
| |
| [Revert from branch] |
| --revert <revision> --branch <branch_num> |
| Example: %(app)s --revert 12345 --branch 187 |
| """ |
| |
| export_map_ = None |
| files_info_ = None |
| delete_map_ = None |
| file_pattern_ = r"[ ]+([MADUC])[ ]+/((?:trunk|branches/.*?)/src(.*)/(.*))" |
| depot_tools_dir_ = os.path.dirname(os.path.abspath(__file__)) |
| |
| |
| def runGcl(subcommand): |
| gcl_path = os.path.join(depot_tools_dir_, "gcl") |
| if not os.path.exists(gcl_path): |
| print "WARNING: gcl not found beside drover.py. Using system gcl instead..." |
| gcl_path = 'gcl' |
| |
| command = "%s %s" % (gcl_path, subcommand) |
| return os.system(command) |
| |
| def gclUpload(revision, author): |
| command = ("upload " + str(revision) + |
| " --send_mail --no_presubmit --reviewers=" + author) |
| return runGcl(command) |
| |
| def getSVNInfo(url, revision): |
| info = {} |
| svn_info = subprocess2.capture( |
| ['svn', 'info', '--non-interactive', '%s@%s' % (url, revision)], |
| stderr=subprocess2.VOID).splitlines() |
| for line in svn_info: |
| match = re.search(r"(.*?):(.*)", line) |
| if match: |
| info[match.group(1).strip()] = match.group(2).strip() |
| return info |
| |
| def isSVNDirty(): |
| svn_status = subprocess2.check_output(['svn', 'status']).splitlines() |
| for line in svn_status: |
| match = re.search(r"^[^X?]", line) |
| if match: |
| return True |
| |
| return False |
| |
| def getAuthor(url, revision): |
| info = getSVNInfo(url, revision) |
| if (info.has_key("Last Changed Author")): |
| return info["Last Changed Author"] |
| return None |
| |
| def isSVNFile(url, revision): |
| info = getSVNInfo(url, revision) |
| if (info.has_key("Node Kind")): |
| if (info["Node Kind"] == "file"): |
| return True |
| return False |
| |
| def isSVNDirectory(url, revision): |
| info = getSVNInfo(url, revision) |
| if (info.has_key("Node Kind")): |
| if (info["Node Kind"] == "directory"): |
| return True |
| return False |
| |
| def inCheckoutRoot(path): |
| info = getSVNInfo(path, "HEAD") |
| if (not info.has_key("Repository Root")): |
| return False |
| repo_root = info["Repository Root"] |
| info = getSVNInfo(os.path.dirname(os.path.abspath(path)), "HEAD") |
| if (info.get("Repository Root", None) != repo_root): |
| return True |
| return False |
| |
| def getRevisionLog(url, revision): |
| """Takes an svn url and gets the associated revision.""" |
| svn_log = subprocess2.check_output( |
| ['svn', 'log', url, '-r', str(revision)], |
| universal_newlines=True).splitlines(True) |
| # Don't include the header lines and the trailing "---..." line. |
| return ''.join(svn_log[3:-1]) |
| |
| def getSVNVersionInfo(): |
| """Extract version information from SVN""" |
| svn_info = subprocess2.check_output(['svn', '--version']).splitlines() |
| info = {} |
| for line in svn_info: |
| match = re.search(r"svn, version ((\d+)\.(\d+)\.(\d+))", line) |
| if match: |
| info['version'] = match.group(1) |
| info['major'] = int(match.group(2)) |
| info['minor'] = int(match.group(3)) |
| info['patch'] = int(match.group(4)) |
| return info |
| |
| return None |
| |
| def isMinimumSVNVersion(major, minor, patch=0): |
| """Test for minimum SVN version""" |
| return _isMinimumSVNVersion(getSVNVersionInfo(), major, minor, patch) |
| |
| def _isMinimumSVNVersion(version, major, minor, patch=0): |
| """Test for minimum SVN version, internal method""" |
| if not version: |
| return False |
| |
| if (version['major'] > major): |
| return True |
| elif (version['major'] < major): |
| return False |
| |
| if (version['minor'] > minor): |
| return True |
| elif (version['minor'] < minor): |
| return False |
| |
| if (version['patch'] >= patch): |
| return True |
| else: |
| return False |
| |
| def checkoutRevision(url, revision, branch_url, revert=False, pop=True): |
| files_info = getFileInfo(url, revision) |
| paths = getBestMergePaths2(files_info, revision) |
| export_map = getBestExportPathsMap2(files_info, revision) |
| |
| command = 'svn checkout -N ' + branch_url |
| print command |
| os.system(command) |
| |
| match = re.search(r"^[a-z]+://.*/(.*)", branch_url) |
| |
| if match: |
| os.chdir(match.group(1)) |
| |
| # This line is extremely important due to the way svn behaves in the |
| # set-depths action. If parents aren't handled before children, the child |
| # directories get clobbered and the merge step fails. |
| paths.sort() |
| |
| # Checkout the directories that already exist |
| for path in paths: |
| if (export_map.has_key(path) and not revert): |
| print "Exclude new directory " + path |
| continue |
| subpaths = path.split('/') |
| #In the normal case, where no url override is specified and it's just |
| # chromium source, it's necessary to remove the 'trunk' from the filepath, |
| # since in the checkout we include 'trunk' or 'branch/\d+'. |
| # |
| # However, when a url is specified we want to preserve that because it's |
| # a part of the filepath and necessary for path operations on svn (because |
| # frankly, we are checking out the correct top level, and not hacking it). |
| if pop: |
| subpaths.pop(0) |
| base = '' |
| for subpath in subpaths: |
| base += '/' + subpath |
| # This logic ensures that you don't empty out any directories |
| if not os.path.exists("." + base): |
| command = ('svn update --depth empty ' + "." + base) |
| print command |
| os.system(command) |
| |
| if (revert): |
| files = getAllFilesInRevision(files_info) |
| else: |
| files = getExistingFilesInRevision(files_info) |
| |
| for f in files: |
| # Prevent the tool from clobbering the src directory |
| if (f == ""): |
| continue |
| command = ('svn up ".' + f + '"') |
| print command |
| os.system(command) |
| |
| def mergeRevision(url, revision): |
| paths = getBestMergePaths(url, revision) |
| export_map = getBestExportPathsMap(url, revision) |
| |
| for path in paths: |
| if export_map.has_key(path): |
| continue |
| command = ('svn merge -N -r ' + str(revision-1) + ":" + str(revision) + " ") |
| command += " --ignore-ancestry " |
| command += " -x --ignore-eol-style " |
| command += url + path + "@" + str(revision) + " ." + path |
| |
| print command |
| os.system(command) |
| |
| def exportRevision(url, revision): |
| paths = getBestExportPathsMap(url, revision).keys() |
| paths.sort() |
| |
| for path in paths: |
| command = ('svn export -N ' + url + path + "@" + str(revision) + " ." + |
| path) |
| print command |
| os.system(command) |
| |
| command = 'svn add .' + path |
| print command |
| os.system(command) |
| |
| def deleteRevision(url, revision): |
| paths = getBestDeletePathsMap(url, revision).keys() |
| paths.sort() |
| paths.reverse() |
| |
| for path in paths: |
| command = "svn delete ." + path |
| print command |
| os.system(command) |
| |
| def revertExportRevision(url, revision): |
| paths = getBestExportPathsMap(url, revision).keys() |
| paths.sort() |
| paths.reverse() |
| |
| for path in paths: |
| command = "svn delete ." + path |
| print command |
| os.system(command) |
| |
| def revertRevision(url, revision): |
| command = ('svn merge --ignore-ancestry -c -%d %s .' % (revision, url)) |
| print command |
| os.system(command) |
| |
| def getFileInfo(url, revision): |
| global files_info_ |
| |
| if (files_info_ != None): |
| return files_info_ |
| |
| svn_log = subprocess2.check_output( |
| ['svn', 'log', url, '-r', str(revision), '-v']).splitlines() |
| |
| info = [] |
| for line in svn_log: |
| # A workaround to dump the (from .*) stuff, regex not so friendly in the 2nd |
| # pass... |
| match = re.search(r"(.*) \(from.*\)", line) |
| if match: |
| line = match.group(1) |
| match = re.search(file_pattern_, line) |
| if match: |
| info.append([match.group(1).strip(), match.group(2).strip(), |
| match.group(3).strip(),match.group(4).strip()]) |
| |
| files_info_ = info |
| return info |
| |
| def getBestMergePaths(url, revision): |
| """Takes an svn url and gets the associated revision.""" |
| return getBestMergePaths2(getFileInfo(url, revision), revision) |
| |
| def getBestMergePaths2(files_info, revision): |
| """Takes an svn url and gets the associated revision.""" |
| return list(set([f[2] for f in files_info])) |
| |
| def getBestExportPathsMap(url, revision): |
| return getBestExportPathsMap2(getFileInfo(url, revision), revision) |
| |
| def getBestExportPathsMap2(files_info, revision): |
| """Takes an svn url and gets the associated revision.""" |
| global export_map_ |
| |
| if export_map_: |
| return export_map_ |
| |
| result = {} |
| for file_info in files_info: |
| if (file_info[0] == "A"): |
| if(isSVNDirectory("svn://svn.chromium.org/chrome/" + file_info[1], |
| revision)): |
| result[file_info[2] + "/" + file_info[3]] = "" |
| |
| export_map_ = result |
| return result |
| |
| def getBestDeletePathsMap(url, revision): |
| return getBestDeletePathsMap2(getFileInfo(url, revision), revision) |
| |
| def getBestDeletePathsMap2(files_info, revision): |
| """Takes an svn url and gets the associated revision.""" |
| global delete_map_ |
| |
| if delete_map_: |
| return delete_map_ |
| |
| result = {} |
| for file_info in files_info: |
| if (file_info[0] == "D"): |
| if(isSVNDirectory("svn://svn.chromium.org/chrome/" + file_info[1], |
| revision)): |
| result[file_info[2] + "/" + file_info[3]] = "" |
| |
| delete_map_ = result |
| return result |
| |
| |
| def getExistingFilesInRevision(files_info): |
| """Checks for existing files in the revision. |
| |
| Anything that's A will require special treatment (either a merge or an |
| export + add) |
| """ |
| return ['%s/%s' % (f[2], f[3]) for f in files_info if f[0] != 'A'] |
| |
| |
| def getAllFilesInRevision(files_info): |
| """Checks for existing files in the revision. |
| |
| Anything that's A will require special treatment (either a merge or an |
| export + add) |
| """ |
| return ['%s/%s' % (f[2], f[3]) for f in files_info] |
| |
| |
| def getSVNAuthInfo(folder=None): |
| """Fetches SVN authorization information in the subversion auth folder and |
| returns it as a dictionary of dictionaries.""" |
| if not folder: |
| if sys.platform == 'win32': |
| folder = '%%APPDATA%\\Subversion\\auth' |
| else: |
| folder = '~/.subversion/auth' |
| folder = os.path.expandvars(os.path.expanduser(folder)) |
| svn_simple_folder = os.path.join(folder, 'svn.simple') |
| results = {} |
| try: |
| for auth_file in os.listdir(svn_simple_folder): |
| # Read the SVN auth file, convert it into a dictionary, and store it. |
| results[auth_file] = dict(re.findall(r'K [0-9]+\n(.*)\nV [0-9]+\n(.*)\n', |
| open(os.path.join(svn_simple_folder, auth_file)).read())) |
| except Exception as _: |
| pass |
| return results |
| |
| |
| def getCurrentSVNUsers(url): |
| """Tries to fetch the current SVN in the current checkout by scanning the |
| SVN authorization folder for a match with the current SVN URL.""" |
| netloc = urlparse.urlparse(url)[1] |
| auth_infos = getSVNAuthInfo() |
| results = [] |
| for _, auth_info in auth_infos.iteritems(): |
| if ('svn:realmstring' in auth_info |
| and netloc in auth_info['svn:realmstring']): |
| username = auth_info['username'] |
| results.append(username) |
| if 'google.com' in username: |
| results.append(username.replace('google.com', 'chromium.org')) |
| return results |
| |
| |
| def prompt(question): |
| while True: |
| print question + " [y|n]:", |
| answer = sys.stdin.readline() |
| if answer.lower().startswith('n'): |
| return False |
| elif answer.lower().startswith('y'): |
| return True |
| |
| |
| def text_prompt(question, default): |
| print question + " [" + default + "]:" |
| answer = sys.stdin.readline() |
| if answer.strip() == "": |
| return default |
| return answer |
| |
| |
| def drover(options, args): |
| revision = options.revert or options.merge |
| |
| # Initialize some variables used below. They can be overwritten by |
| # the drover.properties file. |
| BASE_URL = "svn://svn.chromium.org/chrome" |
| REVERT_ALT_URLS = ['svn://svn.chromium.org/blink', |
| 'svn://svn.chromium.org/chrome-internal', |
| 'svn://svn.chromium.org/native_client'] |
| TRUNK_URL = BASE_URL + "/trunk/src" |
| BRANCH_URL = BASE_URL + "/branches/$branch/src" |
| SKIP_CHECK_WORKING = True |
| PROMPT_FOR_AUTHOR = False |
| NO_ALT_URLS = options.no_alt_urls |
| |
| DEFAULT_WORKING = "drover_" + str(revision) |
| if options.branch: |
| DEFAULT_WORKING += ("_" + options.branch) |
| |
| if not isMinimumSVNVersion(1, 5): |
| print "You need to use at least SVN version 1.5.x" |
| return 1 |
| |
| # Override the default properties if there is a drover.properties file. |
| global file_pattern_ |
| if os.path.exists("drover.properties"): |
| print 'Using options from %s' % os.path.join( |
| os.getcwd(), 'drover.properties') |
| FILE_PATTERN = file_pattern_ |
| f = open("drover.properties") |
| exec(f) |
| f.close() |
| if FILE_PATTERN: |
| file_pattern_ = FILE_PATTERN |
| NO_ALT_URLS = True |
| |
| if options.revert and options.branch: |
| print 'Note: --branch is usually not needed for reverts.' |
| url = BRANCH_URL.replace("$branch", options.branch) |
| elif options.merge and options.sbranch: |
| url = BRANCH_URL.replace("$branch", options.sbranch) |
| elif options.revert: |
| url = options.url or BASE_URL |
| file_pattern_ = r"[ ]+([MADUC])[ ]+((/.*)/(.*))" |
| else: |
| url = TRUNK_URL |
| |
| working = options.workdir or DEFAULT_WORKING |
| |
| if options.local: |
| working = os.getcwd() |
| if not inCheckoutRoot(working): |
| print "'%s' appears not to be the root of a working copy" % working |
| return 1 |
| if (isSVNDirty() and not |
| prompt("Working copy contains uncommitted files. Continue?")): |
| return 1 |
| |
| if options.revert and not NO_ALT_URLS and not options.url: |
| for cur_url in [url] + REVERT_ALT_URLS: |
| try: |
| commit_date_str = getSVNInfo( |
| cur_url, options.revert).get('Last Changed Date', 'x').split()[0] |
| commit_date = datetime.datetime.strptime(commit_date_str, '%Y-%m-%d') |
| if (datetime.datetime.now() - commit_date).days < 180: |
| if cur_url != url: |
| print 'Guessing svn repo: %s.' % cur_url, |
| print 'Use --no-alt-urls to disable heuristic.' |
| url = cur_url |
| break |
| except ValueError: |
| pass |
| command = 'svn log ' + url + " -r "+str(revision) + " -v" |
| os.system(command) |
| |
| if not (options.revertbot or prompt("Is this the correct revision?")): |
| return 0 |
| |
| if (os.path.exists(working)) and not options.local: |
| if not (options.revertbot or SKIP_CHECK_WORKING or |
| prompt("Working directory: '%s' already exists, clobber?" % working)): |
| return 0 |
| gclient_utils.rmtree(working) |
| |
| if not options.local: |
| os.makedirs(working) |
| os.chdir(working) |
| |
| if options.merge: |
| action = "Merge" |
| if not options.local: |
| branch_url = BRANCH_URL.replace("$branch", options.branch) |
| # Checkout everything but stuff that got added into a new dir |
| checkoutRevision(url, revision, branch_url) |
| # Merge everything that changed |
| mergeRevision(url, revision) |
| # "Export" files that were added from the source and add them to branch |
| exportRevision(url, revision) |
| # Delete directories that were deleted (file deletes are handled in the |
| # merge). |
| deleteRevision(url, revision) |
| elif options.revert: |
| action = "Revert" |
| pop_em = not options.url |
| checkoutRevision(url, revision, url, True, pop_em) |
| revertRevision(url, revision) |
| revertExportRevision(url, revision) |
| |
| # Check the base url so we actually find the author who made the change |
| if options.auditor: |
| author = options.auditor |
| else: |
| author = getAuthor(url, revision) |
| if not author: |
| author = getAuthor(TRUNK_URL, revision) |
| |
| # Check that the author of the CL is different than the user making |
| # the revert. If they're the same, then we'll want to prompt the user |
| # for a different reviewer to TBR. |
| current_users = getCurrentSVNUsers(BASE_URL) |
| is_self_revert = options.revert and author in current_users |
| |
| filename = str(revision)+".txt" |
| out = open(filename,"w") |
| drover_title = '%s %s' % (action, revision) |
| revision_log = getRevisionLog(url, revision).splitlines() |
| if revision_log: |
| commit_title = revision_log[0] |
| # Limit title to 68 chars so git log --oneline is <80 chars. |
| max_commit_title = 68 - (len(drover_title) + 3) |
| if len(commit_title) > max_commit_title: |
| commit_title = commit_title[:max_commit_title-3] + '...' |
| drover_title += ' "%s"' % commit_title |
| out.write(drover_title + '\n\n') |
| for line in revision_log: |
| out.write('> %s\n' % line) |
| if author: |
| out.write("\nTBR=" + author) |
| out.close() |
| |
| change_cmd = 'change ' + str(revision) + " " + filename |
| if options.revertbot: |
| if sys.platform == 'win32': |
| os.environ['SVN_EDITOR'] = 'cmd.exe /c exit' |
| else: |
| os.environ['SVN_EDITOR'] = 'true' |
| runGcl(change_cmd) |
| os.unlink(filename) |
| |
| if options.local: |
| return 0 |
| |
| print author |
| print revision |
| print ("gcl upload " + str(revision) + |
| " --send_mail --no_presubmit --reviewers=" + author) |
| |
| if options.revertbot or prompt("Would you like to upload?"): |
| if PROMPT_FOR_AUTHOR or is_self_revert: |
| author = text_prompt("Enter new author or press enter to accept default", |
| author) |
| if options.revertbot and options.revertbot_reviewers: |
| author += "," |
| author += options.revertbot_reviewers |
| gclUpload(revision, author) |
| else: |
| print "Deleting the changelist." |
| print "gcl delete " + str(revision) |
| runGcl("delete " + str(revision)) |
| return 0 |
| |
| # We commit if the reverbot is set to commit automatically, or if this is |
| # not the revertbot and the user agrees. |
| if options.revertbot_commit or (not options.revertbot and |
| prompt("Would you like to commit?")): |
| print "gcl commit " + str(revision) + " --no_presubmit --force" |
| return runGcl("commit " + str(revision) + " --no_presubmit --force") |
| else: |
| return 0 |
| |
| |
| def main(): |
| option_parser = optparse.OptionParser(usage=USAGE % {"app": sys.argv[0]}) |
| option_parser.add_option('-m', '--merge', type="int", |
| help='Revision to merge from trunk to branch') |
| option_parser.add_option('-b', '--branch', |
| help='Branch to revert or merge from') |
| option_parser.add_option('-l', '--local', action='store_true', |
| help='Local working copy to merge to') |
| option_parser.add_option('-s', '--sbranch', |
| help='Source branch for merge') |
| option_parser.add_option('-r', '--revert', type="int", |
| help='Revision to revert') |
| option_parser.add_option('-w', '--workdir', |
| help='subdir to use for the revert') |
| option_parser.add_option('-u', '--url', |
| help='svn url to use for the revert') |
| option_parser.add_option('-a', '--auditor', |
| help='overrides the author for reviewer') |
| option_parser.add_option('--revertbot', action='store_true', |
| default=False) |
| option_parser.add_option('--no-alt-urls', action='store_true', |
| help='Disable heuristics used to determine svn url') |
| option_parser.add_option('--revertbot-commit', action='store_true', |
| default=False) |
| option_parser.add_option('--revertbot-reviewers') |
| options, args = option_parser.parse_args() |
| |
| if not options.merge and not options.revert: |
| option_parser.error("You need at least --merge or --revert") |
| return 1 |
| |
| if options.merge and not (options.branch or options.local): |
| option_parser.error("--merge requires --branch or --local") |
| return 1 |
| |
| if options.local and (options.revert or options.branch): |
| option_parser.error("--local cannot be used with --revert or --branch") |
| return 1 |
| |
| return drover(options, args) |
| |
| |
| if __name__ == "__main__": |
| try: |
| sys.exit(main()) |
| except KeyboardInterrupt: |
| sys.stderr.write('interrupted\n') |
| sys.exit(1) |