Home | History | Annotate | Download | only in tools
      1 #!/usr/bin/python2
      2 
      3 # Copyright 2014 Google Inc.
      4 #
      5 # Use of this source code is governed by a BSD-style license that can be
      6 # found in the LICENSE file.
      7 
      8 """Skia's Chromium DEPS roll script.
      9 
     10 This script:
     11 - searches through the last N Skia git commits to find out the hash that is
     12   associated with the SVN revision number.
     13 - creates a new branch in the Chromium tree, modifies the DEPS file to
     14   point at the given Skia commit, commits, uploads to Rietveld, and
     15   deletes the local copy of the branch.
     16 - creates a whitespace-only commit and uploads that to to Rietveld.
     17 - returns the Chromium tree to its previous state.
     18 
     19 To specify the location of the git executable, set the GIT_EXECUTABLE
     20 environment variable.
     21 
     22 Usage:
     23   %prog -c CHROMIUM_PATH -r REVISION [OPTIONAL_OPTIONS]
     24 """
     25 
     26 
     27 import optparse
     28 import os
     29 import re
     30 import shutil
     31 import subprocess
     32 import sys
     33 import tempfile
     34 
     35 import git_utils
     36 import misc_utils
     37 
     38 
     39 DEFAULT_BOTS_LIST = [
     40     'android_clang_dbg',
     41     'android_dbg',
     42     'android_rel',
     43     'cros_daisy',
     44     'linux',
     45     'linux_asan',
     46     'linux_chromeos',
     47     'linux_chromeos_asan',
     48     'linux_chromium_gn_dbg',
     49     'linux_gpu',
     50     'linux_layout',
     51     'linux_layout_rel',
     52     'mac',
     53     'mac_asan',
     54     'mac_gpu',
     55     'mac_layout',
     56     'mac_layout_rel',
     57     'win',
     58     'win_gpu',
     59     'win_layout',
     60     'win_layout_rel',
     61 ]
     62 
     63 
     64 class DepsRollConfig(object):
     65     """Contains configuration options for this module.
     66 
     67     Attributes:
     68         git: (string) The git executable.
     69         chromium_path: (string) path to a local chromium git repository.
     70         save_branches: (boolean) iff false, delete temporary branches.
     71         verbose: (boolean)  iff false, suppress the output from git-cl.
     72         search_depth: (int) how far back to look for the revision.
     73         skia_url: (string) Skia's git repository.
     74         self.skip_cl_upload: (boolean)
     75         self.cl_bot_list: (list of strings)
     76     """
     77 
     78     # pylint: disable=I0011,R0903,R0902
     79     def __init__(self, options=None):
     80         self.skia_url = 'https://skia.googlesource.com/skia.git'
     81         self.revision_format = (
     82             'git-svn-id: http://skia.googlecode.com/svn/trunk@%d ')
     83 
     84         self.git = git_utils.git_executable()
     85 
     86         if not options:
     87             options = DepsRollConfig.GetOptionParser()
     88         # pylint: disable=I0011,E1103
     89         self.verbose = options.verbose
     90         self.vsp = misc_utils.VerboseSubprocess(self.verbose)
     91         self.save_branches = not options.delete_branches
     92         self.search_depth = options.search_depth
     93         self.chromium_path = options.chromium_path
     94         self.skip_cl_upload = options.skip_cl_upload
     95         # Split and remove empty strigns from the bot list.
     96         self.cl_bot_list = [bot for bot in options.bots.split(',') if bot]
     97         self.skia_git_checkout_path = options.skia_git_path
     98         self.default_branch_name = 'autogenerated_deps_roll_branch'
     99         self.reviewers_list = ','.join([
    100             # 'rmistry (at] google.com',
    101             # 'reed (at] google.com',
    102             # 'bsalomon (at] google.com',
    103             # 'robertphillips (at] google.com',
    104             ])
    105         self.cc_list = ','.join([
    106             # 'skia-team (at] google.com',
    107             ])
    108 
    109     @staticmethod
    110     def GetOptionParser():
    111         # pylint: disable=I0011,C0103
    112         """Returns an optparse.OptionParser object.
    113 
    114         Returns:
    115             An optparse.OptionParser object.
    116 
    117         Called by the main() function.
    118         """
    119         option_parser = optparse.OptionParser(usage=__doc__)
    120         # Anyone using this script on a regular basis should set the
    121         # CHROMIUM_CHECKOUT_PATH environment variable.
    122         option_parser.add_option(
    123             '-c', '--chromium_path', help='Path to local Chromium Git'
    124             ' repository checkout, defaults to CHROMIUM_CHECKOUT_PATH'
    125             ' if that environment variable is set.',
    126             default=os.environ.get('CHROMIUM_CHECKOUT_PATH'))
    127         option_parser.add_option(
    128             '-r', '--revision', type='int', default=None,
    129             help='The Skia SVN revision number, defaults to top of tree.')
    130         option_parser.add_option(
    131             '-g', '--git_hash', default=None,
    132             help='A partial Skia Git hash.  Do not set this and revision.')
    133 
    134         # Anyone using this script on a regular basis should set the
    135         # SKIA_GIT_CHECKOUT_PATH environment variable.
    136         option_parser.add_option(
    137             '', '--skia_git_path',
    138             help='Path of a pure-git Skia repository checkout.  If empty,'
    139             ' a temporary will be cloned.  Defaults to SKIA_GIT_CHECKOUT'
    140             '_PATH, if that environment variable is set.',
    141             default=os.environ.get('SKIA_GIT_CHECKOUT_PATH'))
    142         option_parser.add_option(
    143             '', '--search_depth', type='int', default=100,
    144             help='How far back to look for the revision.')
    145         option_parser.add_option(
    146             '', '--delete_branches', help='Delete the temporary branches',
    147             action='store_true', dest='delete_branches', default=False)
    148         option_parser.add_option(
    149             '', '--verbose', help='Do not suppress the output from `git cl`.',
    150             action='store_true', dest='verbose', default=False)
    151         option_parser.add_option(
    152             '', '--skip_cl_upload', help='Skip the cl upload step; useful'
    153             ' for testing.',
    154             action='store_true', default=False)
    155 
    156         default_bots_help = (
    157             'Comma-separated list of bots, defaults to a list of %d bots.'
    158             '  To skip `git cl try`, set this to an empty string.'
    159             % len(DEFAULT_BOTS_LIST))
    160         default_bots = ','.join(DEFAULT_BOTS_LIST)
    161         option_parser.add_option(
    162             '', '--bots', help=default_bots_help, default=default_bots)
    163 
    164         return option_parser
    165 
    166 
    167 class DepsRollError(Exception):
    168     """Exceptions specific to this module."""
    169     pass
    170 
    171 
    172 def get_svn_revision(config, commit):
    173     """Works in both git and git-svn. returns a string."""
    174     svn_format = (
    175         '(git-svn-id: [^@ ]+@|SVN changes up to revision |'
    176         'LKGR w/ DEPS up to revision )(?P<return>[0-9]+)')
    177     svn_revision = misc_utils.ReSearch.search_within_output(
    178         config.verbose, svn_format, None,
    179         [config.git, 'log', '-n', '1', '--format=format:%B', commit])
    180     if not svn_revision:
    181         raise DepsRollError(
    182             'Revision number missing from Chromium origin/master.')
    183     return int(svn_revision)
    184 
    185 
    186 class SkiaGitCheckout(object):
    187     """Class to create a temporary skia git checkout, if necessary.
    188     """
    189     # pylint: disable=I0011,R0903
    190 
    191     def __init__(self, config, depth):
    192         self._config = config
    193         self._depth = depth
    194         self._use_temp = None
    195         self._original_cwd = None
    196 
    197     def __enter__(self):
    198         config = self._config
    199         git = config.git
    200         skia_dir = None
    201         self._original_cwd = os.getcwd()
    202         if config.skia_git_checkout_path:
    203             if config.skia_git_checkout_path != os.curdir:
    204                 skia_dir = config.skia_git_checkout_path
    205                 ## Update origin/master if needed.
    206                 if self._config.verbose:
    207                     print '~~$', 'cd', skia_dir
    208                 os.chdir(skia_dir)
    209             config.vsp.check_call([git, 'fetch', '-q', 'origin'])
    210             self._use_temp = None
    211         else:
    212             skia_dir = tempfile.mkdtemp(prefix='git_skia_tmp_')
    213             self._use_temp = skia_dir
    214             try:
    215                 os.chdir(skia_dir)
    216                 config.vsp.check_call(
    217                     [git, 'clone', '-q', '--depth=%d' % self._depth,
    218                      '--single-branch', config.skia_url, '.'])
    219             except (OSError, subprocess.CalledProcessError) as error:
    220                 shutil.rmtree(skia_dir)
    221                 raise error
    222 
    223     def __exit__(self, etype, value, traceback):
    224         if self._config.skia_git_checkout_path != os.curdir:
    225             if self._config.verbose:
    226                 print '~~$', 'cd', self._original_cwd
    227             os.chdir(self._original_cwd)
    228         if self._use_temp:
    229             shutil.rmtree(self._use_temp)
    230 
    231 
    232 def revision_and_hash(config):
    233     """Finds revision number and git hash of origin/master in the Skia tree.
    234 
    235     Args:
    236         config: (roll_deps.DepsRollConfig) object containing options.
    237 
    238     Returns:
    239         A tuple (revision, hash)
    240             revision: (int) SVN revision number.
    241             git_hash: (string) full Git commit hash.
    242 
    243     Raises:
    244         roll_deps.DepsRollError: if the revision can't be found.
    245         OSError: failed to execute git or git-cl.
    246         subprocess.CalledProcessError: git returned unexpected status.
    247     """
    248     with SkiaGitCheckout(config, 1):
    249         revision = get_svn_revision(config, 'origin/master')
    250         git_hash = config.vsp.strip_output(
    251             [config.git, 'show-ref', 'origin/master', '--hash'])
    252         if not git_hash:
    253             raise DepsRollError('Git hash can not be found.')
    254     return revision, git_hash
    255 
    256 
    257 def revision_and_hash_from_revision(config, revision):
    258     """Finds revision number and git hash of a commit in the Skia tree.
    259 
    260     Args:
    261         config: (roll_deps.DepsRollConfig) object containing options.
    262         revision: (int) SVN revision number.
    263 
    264     Returns:
    265         A tuple (revision, hash)
    266             revision: (int) SVN revision number.
    267             git_hash: (string) full Git commit hash.
    268 
    269     Raises:
    270         roll_deps.DepsRollError: if the revision can't be found.
    271         OSError: failed to execute git or git-cl.
    272         subprocess.CalledProcessError: git returned unexpected status.
    273     """
    274     with SkiaGitCheckout(config, config.search_depth):
    275         revision_regex = config.revision_format % revision
    276         git_hash = config.vsp.strip_output(
    277             [config.git, 'log', '--grep', revision_regex,
    278              '--format=format:%H', 'origin/master'])
    279         if not git_hash:
    280             raise DepsRollError('Git hash can not be found.')
    281     return revision, git_hash
    282 
    283 
    284 def revision_and_hash_from_partial(config, partial_hash):
    285     """Returns the SVN revision number and full git hash.
    286 
    287     Args:
    288         config: (roll_deps.DepsRollConfig) object containing options.
    289         partial_hash: (string) Partial git commit hash.
    290 
    291     Returns:
    292         A tuple (revision, hash)
    293             revision: (int) SVN revision number.
    294             git_hash: (string) full Git commit hash.
    295 
    296     Raises:
    297         roll_deps.DepsRollError: if the revision can't be found.
    298         OSError: failed to execute git or git-cl.
    299         subprocess.CalledProcessError: git returned unexpected status.
    300     """
    301     with SkiaGitCheckout(config, config.search_depth):
    302         git_hash = config.vsp.strip_output(
    303             ['git', 'log', '-n', '1', '--format=format:%H', partial_hash])
    304         if not git_hash:
    305             raise DepsRollError('Partial Git hash can not be found.')
    306         revision = get_svn_revision(config, git_hash)
    307     return revision, git_hash
    308 
    309 
    310 def change_skia_deps(revision, git_hash, depspath):
    311     """Update the DEPS file.
    312 
    313     Modify the skia_revision and skia_hash entries in the given DEPS file.
    314 
    315     Args:
    316         revision: (int) Skia SVN revision.
    317         git_hash: (string) Skia Git hash.
    318         depspath: (string) path to DEPS file.
    319     """
    320     temp_file = tempfile.NamedTemporaryFile(delete=False,
    321                                             prefix='skia_DEPS_ROLL_tmp_')
    322     try:
    323         deps_regex_rev = re.compile('"skia_revision": "[0-9]*",')
    324         deps_regex_hash = re.compile('"skia_hash": "[0-9a-f]*",')
    325 
    326         deps_regex_rev_repl = '"skia_revision": "%d",' % revision
    327         deps_regex_hash_repl = '"skia_hash": "%s",' % git_hash
    328 
    329         with open(depspath, 'r') as input_stream:
    330             for line in input_stream:
    331                 line = deps_regex_rev.sub(deps_regex_rev_repl, line)
    332                 line = deps_regex_hash.sub(deps_regex_hash_repl, line)
    333                 temp_file.write(line)
    334     finally:
    335         temp_file.close()
    336     shutil.move(temp_file.name, depspath)
    337 
    338 
    339 def git_cl_uploader(config, message, file_list):
    340     """Create a commit in the current git branch; upload via git-cl.
    341 
    342     Assumes that you are already on the branch you want to be on.
    343 
    344     Args:
    345         config: (roll_deps.DepsRollConfig) object containing options.
    346         message: (string) the commit message, can be multiline.
    347         file_list: (list of strings) list of filenames to pass to `git add`.
    348 
    349     Returns:
    350         The output of `git cl issue`, if not config.skip_cl_upload, else ''.
    351     """
    352 
    353     git, vsp = config.git, config.vsp
    354     svn_info = str(get_svn_revision(config, 'HEAD'))
    355 
    356     for filename in file_list:
    357         assert os.path.exists(filename)
    358         vsp.check_call([git, 'add', filename])
    359 
    360     vsp.check_call([git, 'commit', '-q', '-m', message])
    361 
    362     git_cl = [git, 'cl', 'upload', '-f',
    363               '--bypass-hooks', '--bypass-watchlists']
    364     if config.cc_list:
    365         git_cl.append('--cc=%s' % config.cc_list)
    366     if config.reviewers_list:
    367         git_cl.append('--reviewers=%s' % config.reviewers_list)
    368 
    369     git_try = [
    370         git, 'cl', 'try', '-m', 'tryserver.chromium', '--revision', svn_info]
    371     git_try.extend([arg for bot in config.cl_bot_list for arg in ('-b', bot)])
    372 
    373     branch_name = git_utils.git_branch_name(vsp.verbose)
    374 
    375     if config.skip_cl_upload:
    376         space = '   '
    377         print 'You should call:'
    378         print '%scd %s' % (space, os.getcwd())
    379         misc_utils.print_subprocess_args(space, [git, 'checkout', branch_name])
    380         misc_utils.print_subprocess_args(space, git_cl)
    381         if config.cl_bot_list:
    382             misc_utils.print_subprocess_args(space, git_try)
    383         print
    384         return ''
    385     else:
    386         vsp.check_call(git_cl)
    387         issue = vsp.strip_output([git, 'cl', 'issue'])
    388         if config.cl_bot_list:
    389             vsp.check_call(git_try)
    390         return issue
    391 
    392 
    393 def roll_deps(config, revision, git_hash):
    394     """Upload changed DEPS and a whitespace change.
    395 
    396     Given the correct git_hash, create two Reitveld issues.
    397 
    398     Args:
    399         config: (roll_deps.DepsRollConfig) object containing options.
    400         revision: (int) Skia SVN revision.
    401         git_hash: (string) Skia Git hash.
    402 
    403     Returns:
    404         a tuple containing textual description of the two issues.
    405 
    406     Raises:
    407         OSError: failed to execute git or git-cl.
    408         subprocess.CalledProcessError: git returned unexpected status.
    409     """
    410 
    411     git = config.git
    412     with misc_utils.ChangeDir(config.chromium_path, config.verbose):
    413         config.vsp.check_call([git, 'fetch', '-q', 'origin'])
    414 
    415         old_revision = misc_utils.ReSearch.search_within_output(
    416             config.verbose, '"skia_revision": "(?P<return>[0-9]+)",', None,
    417             [git, 'show', 'origin/master:DEPS'])
    418         assert old_revision
    419         if revision == int(old_revision):
    420             print 'DEPS is up to date!'
    421             return (None, None)
    422 
    423         master_hash = config.vsp.strip_output(
    424             [git, 'show-ref', 'origin/master', '--hash'])
    425         master_revision = get_svn_revision(config, 'origin/master')
    426 
    427         # master_hash[8] gives each whitespace CL a unique name.
    428         if config.save_branches:
    429             branch = 'control_%s' % master_hash[:8]
    430         else:
    431             branch = None
    432         message = ('whitespace change %s\n\n'
    433                    'Chromium base revision: %d / %s\n\n'
    434                    'This CL was created by Skia\'s roll_deps.py script.\n'
    435                   ) % (master_hash[:8], master_revision, master_hash[:8])
    436         with git_utils.ChangeGitBranch(branch, 'origin/master',
    437                                        config.verbose):
    438             branch = git_utils.git_branch_name(config.vsp.verbose)
    439 
    440             with open('build/whitespace_file.txt', 'a') as output_stream:
    441                 output_stream.write('\nCONTROL\n')
    442 
    443             whitespace_cl = git_cl_uploader(
    444                 config, message, ['build/whitespace_file.txt'])
    445 
    446             control_url = misc_utils.ReSearch.search_within_string(
    447                 whitespace_cl, '(?P<return>https?://[^) ]+)', '?')
    448             if config.save_branches:
    449                 whitespace_cl = '%s\n    branch: %s' % (whitespace_cl, branch)
    450 
    451         if config.save_branches:
    452             branch = 'roll_%d_%s' % (revision, master_hash[:8])
    453         else:
    454             branch = None
    455         message = (
    456             'roll skia DEPS to %d\n\n'
    457             'Chromium base revision: %d / %s\n'
    458             'Old Skia revision: %s\n'
    459             'New Skia revision: %d\n'
    460             'Control CL: %s\n\n'
    461             'This CL was created by Skia\'s roll_deps.py script.\n\n'
    462             'Bypassing commit queue trybots:\n'
    463             'NOTRY=true\n'
    464             % (revision, master_revision, master_hash[:8],
    465                old_revision, revision, control_url))
    466         with git_utils.ChangeGitBranch(branch, 'origin/master',
    467                                        config.verbose):
    468             branch = git_utils.git_branch_name(config.vsp.verbose)
    469 
    470             change_skia_deps(revision, git_hash, 'DEPS')
    471             deps_cl = git_cl_uploader(config, message, ['DEPS'])
    472             if config.save_branches:
    473                 deps_cl = '%s\n    branch: %s' % (deps_cl, branch)
    474 
    475         return deps_cl, whitespace_cl
    476 
    477 
    478 def find_hash_and_roll_deps(config, revision=None, partial_hash=None):
    479     """Call find_hash_from_revision() and roll_deps().
    480 
    481     The calls to git will be verbose on standard output.  After a
    482     successful upload of both issues, print links to the new
    483     codereview issues.
    484 
    485     Args:
    486         config: (roll_deps.DepsRollConfig) object containing options.
    487         revision: (int or None) the Skia SVN revision number or None
    488             to use the tip of the tree.
    489         partial_hash: (string or None) a partial pure-git Skia commit
    490             hash.  Don't pass both partial_hash and revision.
    491 
    492     Raises:
    493         roll_deps.DepsRollError: if the revision can't be found.
    494         OSError: failed to execute git or git-cl.
    495         subprocess.CalledProcessError: git returned unexpected status.
    496     """
    497 
    498     if revision and partial_hash:
    499         raise DepsRollError('Pass revision or partial_hash, not both.')
    500 
    501     if partial_hash:
    502         revision, git_hash = revision_and_hash_from_partial(
    503             config, partial_hash)
    504     elif revision:
    505         revision, git_hash = revision_and_hash_from_revision(config, revision)
    506     else:
    507         revision, git_hash = revision_and_hash(config)
    508 
    509     print 'revision=%r\nhash=%r\n' % (revision, git_hash)
    510 
    511     deps_issue, whitespace_issue = roll_deps(config, revision, git_hash)
    512 
    513     if deps_issue and whitespace_issue:
    514         print 'DEPS roll:\n    %s\n' % deps_issue
    515         print 'Whitespace change:\n    %s\n' % whitespace_issue
    516     else:
    517         print >> sys.stderr, 'No issues created.'
    518 
    519 
    520 def main(args):
    521     """main function; see module-level docstring and GetOptionParser help.
    522 
    523     Args:
    524         args: sys.argv[1:]-type argument list.
    525     """
    526     option_parser = DepsRollConfig.GetOptionParser()
    527     options = option_parser.parse_args(args)[0]
    528 
    529     if not options.chromium_path:
    530         option_parser.error('Must specify chromium_path.')
    531     if not os.path.isdir(options.chromium_path):
    532         option_parser.error('chromium_path must be a directory.')
    533 
    534     if not git_utils.git_executable():
    535         option_parser.error('Invalid git executable.')
    536 
    537     config = DepsRollConfig(options)
    538     find_hash_and_roll_deps(config, options.revision, options.git_hash)
    539 
    540 
    541 if __name__ == '__main__':
    542     main(sys.argv[1:])
    543 
    544