| #!/usr/bin/python |
| # |
| # Copyright (C) 2012 The Android Open Source Project |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); |
| # you may not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| |
| """Merge Chromium into the Android tree.""" |
| |
| import contextlib |
| import logging |
| import optparse |
| import os |
| import re |
| import sys |
| import urllib2 |
| |
| import merge_common |
| |
| |
| # We need to import this *after* merging from upstream to get the latest |
| # version. Set it to none here to catch uses before it's imported. |
| webview_licenses = None |
| |
| |
| AUTOGEN_MESSAGE = 'This commit was generated by merge_from_chromium.py.' |
| SRC_GIT_BRANCH = 'refs/remotes/history/upstream-master' |
| |
| |
| def _ReadGitFile(sha1, path, git_url=None, git_branch=None): |
| """Reads a file from a (possibly remote) git project at a specific revision. |
| |
| Args: |
| sha1: The SHA1 at which to read. |
| path: The relative path of the file to read. |
| git_url: The URL of the git server, if reading a remote project. |
| git_branch: The branch to fetch, if reading a remote project. |
| Returns: |
| The contents of the specified file. |
| """ |
| if git_url: |
| merge_common.GetCommandStdout(['git', 'fetch', '-f', git_url, git_branch]) |
| return merge_common.GetCommandStdout(['git', 'show', '%s:%s' % (sha1, path)]) |
| |
| |
| def _ParseDEPS(deps_content): |
| """Parses the .DEPS.git file from Chromium and returns its contents. |
| |
| Args: |
| deps_content: The contents of the .DEPS.git file as text. |
| Returns: |
| A dictionary of the contents of .DEPS.git at the specified revision |
| """ |
| |
| class FromImpl(object): |
| """Used to implement the From syntax.""" |
| |
| def __init__(self, module_name): |
| self.module_name = module_name |
| |
| def __str__(self): |
| return 'From("%s")' % self.module_name |
| |
| class _VarImpl(object): |
| def __init__(self, custom_vars, local_scope): |
| self._custom_vars = custom_vars |
| self._local_scope = local_scope |
| |
| def Lookup(self, var_name): |
| """Implements the Var syntax.""" |
| if var_name in self._custom_vars: |
| return self._custom_vars[var_name] |
| elif var_name in self._local_scope.get('vars', {}): |
| return self._local_scope['vars'][var_name] |
| raise Exception('Var is not defined: %s' % var_name) |
| |
| tmp_locals = {} |
| var = _VarImpl({}, tmp_locals) |
| tmp_globals = {'From': FromImpl, 'Var': var.Lookup, 'deps_os': {}} |
| exec(deps_content) in tmp_globals, tmp_locals |
| return tmp_locals |
| |
| |
| def _GetProjectMergeInfo(projects, deps_vars): |
| """Gets the git URL and SHA1 for each project based on .DEPS.git. |
| |
| Args: |
| projects: The list of projects to consider. |
| deps_vars: The dictionary of dependencies from .DEPS.git. |
| Returns: |
| A dictionary from project to git URL and SHA1 - 'path: (url, sha1)' |
| Raises: |
| TemporaryMergeError: if a project to be merged is not found in .DEPS.git. |
| """ |
| deps_fallback_order = [ |
| deps_vars['deps'], |
| deps_vars['deps_os']['unix'], |
| deps_vars['deps_os']['android'], |
| ] |
| result = {} |
| for path in projects: |
| for deps in deps_fallback_order: |
| if len(path) > 0: |
| upstream_path = os.path.join('src', path) |
| else: |
| upstream_path = 'src' |
| url_plus_sha1 = deps.get(upstream_path) |
| if url_plus_sha1: |
| break |
| else: |
| raise merge_common.TemporaryMergeError( |
| 'Could not find .DEPS.git entry for project %s. This probably ' |
| 'means that the project list in merge_from_chromium.py needs to be ' |
| 'updated.' % path) |
| match = re.match('(.*?)@(.*)', url_plus_sha1) |
| url = match.group(1) |
| sha1 = match.group(2) |
| logging.debug(' Got URL %s and SHA1 %s for project %s', url, sha1, path) |
| result[path] = {'url': url, 'sha1': sha1} |
| return result |
| |
| |
| def _MergeProjects(version, root_sha1, target, unattended, buildspec_url): |
| """Merges each required Chromium project into the Android repository. |
| |
| .DEPS.git is consulted to determine which revision each project must be merged |
| at. Only a whitelist of required projects are merged. |
| |
| Args: |
| version: The version to mention in generated commit messages. |
| root_sha1: The git hash to merge in the root repository. |
| target: The target branch to merge to. |
| unattended: Run in unattended mode. |
| buildspec_url: URL for buildspec repository, when merging a branch. |
| Raises: |
| TemporaryMergeError: If incompatibly licensed code is left after pruning. |
| """ |
| # The logic for this step lives here, in the Android tree, as it makes no |
| # sense for a Chromium tree to know about this merge. |
| |
| if unattended: |
| branch_create_flag = '-B' |
| else: |
| branch_create_flag = '-b' |
| branch_name = 'merge-from-chromium-%s' % version |
| |
| logging.debug('Parsing DEPS ...') |
| if root_sha1: |
| deps_content = _ReadGitFile(root_sha1, '.DEPS.git') |
| else: |
| deps_content = _ReadGitFile('FETCH_HEAD', |
| 'releases/' + version + '/.DEPS.git', |
| buildspec_url, |
| 'master') |
| |
| deps_vars = _ParseDEPS(deps_content) |
| |
| merge_info = _GetProjectMergeInfo(merge_common.THIRD_PARTY_PROJECTS, |
| deps_vars) |
| |
| for path in merge_info: |
| # webkit needs special handling as we have a local mirror |
| local_mirrored = path == 'third_party/WebKit' |
| url = merge_info[path]['url'] |
| sha1 = merge_info[path]['sha1'] |
| dest_dir = os.path.join(merge_common.REPOSITORY_ROOT, path) |
| if local_mirrored: |
| remote = 'history' |
| else: |
| remote = 'goog' |
| merge_common.GetCommandStdout(['git', 'checkout', |
| branch_create_flag, branch_name, |
| '-t', remote + '/' + target], |
| cwd=dest_dir) |
| if not local_mirrored or not root_sha1: |
| logging.debug('Fetching project %s at %s ...', path, sha1) |
| fetch_args = ['git', 'fetch', url, sha1] |
| merge_common.GetCommandStdout(fetch_args, cwd=dest_dir) |
| if merge_common.GetCommandStdout(['git', 'rev-list', '-1', 'HEAD..' + sha1], |
| cwd=dest_dir): |
| logging.debug('Merging project %s at %s ...', path, sha1) |
| # Merge conflicts make git merge return 1, so ignore errors |
| merge_common.GetCommandStdout(['git', 'merge', '--no-commit', sha1], |
| cwd=dest_dir, ignore_errors=True) |
| merge_common.CheckNoConflictsAndCommitMerge( |
| 'Merge %s from %s at %s\n\n%s' % (path, url, sha1, AUTOGEN_MESSAGE), |
| cwd=dest_dir, unattended=unattended) |
| else: |
| logging.debug('No new commits to merge in project %s', path) |
| |
| # Handle root repository separately. |
| merge_common.GetCommandStdout(['git', 'checkout', |
| branch_create_flag, branch_name, |
| '-t', 'history/' + target]) |
| if not root_sha1: |
| merge_info = _GetProjectMergeInfo([''], deps_vars) |
| url = merge_info['']['url'] |
| root_sha1 = merge_info['']['sha1'] |
| merge_common.GetCommandStdout(['git', 'fetch', url, root_sha1]) |
| logging.debug('Merging Chromium at %s ...', root_sha1) |
| # Merge conflicts make git merge return 1, so ignore errors |
| merge_common.GetCommandStdout(['git', 'merge', '--no-commit', root_sha1], |
| ignore_errors=True) |
| merge_common.CheckNoConflictsAndCommitMerge( |
| 'Merge Chromium at %s (%s)\n\n%s' |
| % (version, root_sha1, AUTOGEN_MESSAGE), unattended=unattended) |
| |
| logging.debug('Getting directories to exclude ...') |
| |
| # We import this now that we have merged the latest version. |
| # It imports to a global in order that it can be used to generate NOTICE |
| # later. We also disable writing bytecode to keep the source tree clean. |
| sys.path.append(os.path.join(merge_common.REPOSITORY_ROOT, 'android_webview', |
| 'tools')) |
| sys.dont_write_bytecode = True |
| global webview_licenses |
| import webview_licenses |
| import known_issues |
| |
| for path, exclude_list in known_issues.KNOWN_INCOMPATIBLE.iteritems(): |
| logging.debug(' %s', '\n '.join(os.path.join(path, x) for x in |
| exclude_list)) |
| dest_dir = os.path.join(merge_common.REPOSITORY_ROOT, path) |
| merge_common.GetCommandStdout(['git', 'rm', '-rf', '--ignore-unmatch'] + |
| exclude_list, cwd=dest_dir) |
| if _ModifiedFilesInIndex(dest_dir): |
| merge_common.GetCommandStdout(['git', 'commit', '-m', |
| 'Exclude unwanted directories'], |
| cwd=dest_dir) |
| |
| |
| def _CheckLicenses(): |
| """Check that no incompatibly licensed directories exist.""" |
| directories_left_over = webview_licenses.GetIncompatibleDirectories() |
| if directories_left_over: |
| raise merge_common.TemporaryMergeError( |
| 'Incompatibly licensed directories remain: ' + |
| '\n'.join(directories_left_over)) |
| |
| |
| def _GenerateMakefiles(version, unattended): |
| """Run gyp to generate the Android build system makefiles. |
| |
| Args: |
| version: The version to mention in generated commit messages. |
| unattended: Run in unattended mode. |
| """ |
| logging.debug('Generating makefiles ...') |
| |
| # TODO(torne): come up with a way to deal with hooks from DEPS properly |
| |
| # TODO(torne): The .tmp files are generated by |
| # third_party/WebKit/Source/WebCore/WebCore.gyp/WebCore.gyp into the source |
| # tree. We should avoid this, or at least use a more specific name to avoid |
| # accidentally removing or adding other files. |
| for path in merge_common.ALL_PROJECTS: |
| dest_dir = os.path.join(merge_common.REPOSITORY_ROOT, path) |
| merge_common.GetCommandStdout(['git', 'rm', '--ignore-unmatch', |
| 'GypAndroid.*.mk', '*.target.*.mk', |
| '*.host.*.mk', '*.tmp'], cwd=dest_dir) |
| |
| try: |
| merge_common.GetCommandStdout(['android_webview/tools/gyp_webview', 'all']) |
| except merge_common.MergeError as e: |
| if not unattended: |
| raise |
| else: |
| for path in merge_common.ALL_PROJECTS: |
| merge_common.GetCommandStdout( |
| ['git', 'reset', '--hard'], |
| cwd=os.path.join(merge_common.REPOSITORY_ROOT, path)) |
| raise merge_common.TemporaryMergeError('Makefile generation failed: ' + |
| str(e)) |
| |
| for path in merge_common.ALL_PROJECTS: |
| dest_dir = os.path.join(merge_common.REPOSITORY_ROOT, path) |
| # git add doesn't have an --ignore-unmatch so we have to do this instead: |
| merge_common.GetCommandStdout(['git', 'add', '-f', 'GypAndroid.*.mk'], |
| ignore_errors=True, cwd=dest_dir) |
| merge_common.GetCommandStdout(['git', 'add', '-f', '*.target.*.mk'], |
| ignore_errors=True, cwd=dest_dir) |
| merge_common.GetCommandStdout(['git', 'add', '-f', '*.host.*.mk'], |
| ignore_errors=True, cwd=dest_dir) |
| merge_common.GetCommandStdout(['git', 'add', '-f', '*.tmp'], |
| ignore_errors=True, cwd=dest_dir) |
| # Only try to commit the makefiles if something has actually changed. |
| if _ModifiedFilesInIndex(dest_dir): |
| merge_common.GetCommandStdout( |
| ['git', 'commit', '-m', |
| 'Update makefiles after merge of Chromium at %s\n\n%s' % |
| (version, AUTOGEN_MESSAGE)], cwd=dest_dir) |
| |
| |
| def _ModifiedFilesInIndex(cwd=merge_common.REPOSITORY_ROOT): |
| """Returns true if git's index contains any changes.""" |
| status = merge_common.GetCommandStdout(['git', 'status', '--porcelain'], |
| cwd=cwd) |
| return re.search(r'^[MADRC]', status, flags=re.MULTILINE) is not None |
| |
| |
| def _GenerateNoticeFile(version): |
| """Generates and commits a NOTICE file containing code licenses. |
| |
| This covers all third-party code (from Android's perspective) that lives in |
| the Chromium tree. |
| |
| Args: |
| version: The version to mention in generated commit messages. |
| """ |
| logging.debug('Regenerating NOTICE file ...') |
| |
| contents = webview_licenses.GenerateNoticeFile() |
| |
| with open(os.path.join(merge_common.REPOSITORY_ROOT, 'NOTICE'), 'w') as f: |
| f.write(contents) |
| merge_common.GetCommandStdout(['git', 'add', 'NOTICE']) |
| # Only try to commit the NOTICE update if the file has actually changed. |
| if _ModifiedFilesInIndex(): |
| merge_common.GetCommandStdout([ |
| 'git', 'commit', '-m', |
| 'Update NOTICE file after merge of Chromium at %s\n\n%s' |
| % (version, AUTOGEN_MESSAGE)]) |
| |
| |
| def _GenerateLastChange(version): |
| """Write a build/util/LASTCHANGE file containing the current revision. |
| |
| The revision number is compiled into the binary at build time from this file. |
| |
| Args: |
| version: The version to mention in generated commit messages. |
| """ |
| logging.debug('Updating LASTCHANGE ...') |
| svn_revision, sha1 = _GetSVNRevisionAndSHA1('HEAD', 'HEAD') |
| with open(os.path.join(merge_common.REPOSITORY_ROOT, 'build/util/LASTCHANGE'), |
| 'w') as f: |
| f.write('LASTCHANGE=%s\n' % svn_revision) |
| merge_common.GetCommandStdout(['git', 'add', '-f', 'build/util/LASTCHANGE']) |
| logging.debug('Updating LASTCHANGE.blink ...') |
| with open(os.path.join(merge_common.REPOSITORY_ROOT, |
| 'build/util/LASTCHANGE.blink'), 'w') as f: |
| f.write('LASTCHANGE=%s\n' % _GetBlinkRevision()) |
| merge_common.GetCommandStdout(['git', 'add', '-f', |
| 'build/util/LASTCHANGE.blink']) |
| if _ModifiedFilesInIndex(): |
| merge_common.GetCommandStdout([ |
| 'git', 'commit', '-m', |
| 'Update LASTCHANGE file after merge of Chromium at %s\n\n%s' |
| % (version, AUTOGEN_MESSAGE)]) |
| |
| |
| def GetLKGR(): |
| """Fetch the last known good release from Chromium's dashboard. |
| |
| Returns: |
| The last known good SVN revision. |
| """ |
| with contextlib.closing( |
| urllib2.urlopen('https://chromium-status.appspot.com/lkgr')) as lkgr: |
| return int(lkgr.read()) |
| |
| |
| def GetHEAD(): |
| """Fetch the latest HEAD revision from the git mirror of the Chromium svn |
| repo. |
| |
| Returns: |
| The latest HEAD SVN revision. |
| """ |
| (svn_revision, root_sha1) = _GetSVNRevisionAndSHA1(SRC_GIT_BRANCH, |
| 'HEAD') |
| return int(svn_revision) |
| |
| |
| def _ParseSvnRevisionFromGitCommitMessage(commit_message): |
| return re.search(r'^git-svn-id: .*@([0-9]+)', commit_message, |
| flags=re.MULTILINE).group(1) |
| |
| |
| def _GetSVNRevisionFromSha(sha1): |
| commit = merge_common.GetCommandStdout([ |
| 'git', 'show', '--format=%H%n%b', sha1]) |
| return _ParseSvnRevisionFromGitCommitMessage(commit) |
| |
| |
| def _GetSVNRevisionAndSHA1(git_branch, svn_revision): |
| logging.debug('Getting SVN revision and SHA1 ...') |
| |
| if svn_revision == 'HEAD': |
| # Just use the latest commit. |
| commit = merge_common.GetCommandStdout([ |
| 'git', 'log', '-n1', '--grep=git-svn-id:', '--format=%H%n%b', |
| git_branch]) |
| sha1 = commit.split()[0] |
| svn_revision = _ParseSvnRevisionFromGitCommitMessage(commit) |
| return (svn_revision, sha1) |
| |
| if svn_revision is None: |
| # Fetch LKGR from upstream. |
| svn_revision = GetLKGR() |
| output = merge_common.GetCommandStdout([ |
| 'git', 'log', '--grep=git-svn-id: .*@%s' % svn_revision, |
| '--format=%H', git_branch]) |
| if not output: |
| raise merge_common.TemporaryMergeError('Revision %s not found in git repo.' |
| % svn_revision) |
| # The log grep will sometimes match reverts/reapplies of commits. We take the |
| # oldest (last) match because the first time it appears in history is |
| # overwhelmingly likely to be the correct commit. |
| sha1 = output.split()[-1] |
| return (svn_revision, sha1) |
| |
| |
| def _GetBlinkRevision(): |
| commit = merge_common.GetCommandStdout([ |
| 'git', 'log', '-n1', '--grep=git-svn-id:', '--format=%H%n%b'], |
| cwd=os.path.join(merge_common.REPOSITORY_ROOT, 'third_party', 'WebKit')) |
| return _ParseSvnRevisionFromGitCommitMessage(commit) |
| |
| |
| def Snapshot(svn_revision, root_sha1, release, target, unattended, |
| buildspec_url): |
| """Takes a snapshot of the Chromium tree and merges it into Android. |
| |
| Android makefiles and a top-level NOTICE file are generated and committed |
| after the merge. |
| |
| Args: |
| svn_revision: The SVN revision in the Chromium repository to merge from. |
| root_sha1: The sha1 in the Chromium git mirror to merge from. |
| release: The Chromium release version to merge from (e.g. "30.0.1599.20"). |
| Only one of svn_revision, root_sha1 and release should be |
| specified. |
| target: The target branch to merge to. |
| unattended: Run in unattended mode. |
| buildspec_url: URL for buildspec repository, used when merging a release. |
| |
| Returns: |
| True if new commits were merged; False if no new commits were present. |
| """ |
| if svn_revision: |
| svn_revision, root_sha1 = _GetSVNRevisionAndSHA1(SRC_GIT_BRANCH, |
| svn_revision) |
| elif root_sha1: |
| svn_revision = _GetSVNRevisionFromSha(root_sha1) |
| |
| if svn_revision and root_sha1: |
| version = svn_revision |
| if not merge_common.GetCommandStdout(['git', 'rev-list', '-1', |
| 'HEAD..' + root_sha1]): |
| logging.info('No new commits to merge at %s (%s)', |
| svn_revision, root_sha1) |
| return False |
| elif release: |
| version = release |
| root_sha1 = None |
| else: |
| raise merge_common.MergeError('No merge source specified') |
| |
| logging.info('Snapshotting Chromium at %s (%s)', version, root_sha1) |
| |
| # 1. Merge, accounting for excluded directories |
| _MergeProjects(version, root_sha1, target, unattended, buildspec_url) |
| |
| # 2. Generate Android makefiles |
| _GenerateMakefiles(version, unattended) |
| |
| # 3. Check for incompatible licenses |
| _CheckLicenses() |
| |
| # 4. Generate Android NOTICE file |
| _GenerateNoticeFile(version) |
| |
| # 5. Generate LASTCHANGE file |
| _GenerateLastChange(version) |
| |
| return True |
| |
| |
| def Push(version, target): |
| """Push the finished snapshot to the Android repository.""" |
| src = 'merge-from-chromium-%s' % version |
| # Use forced pushes ('+' prefix) for the temporary and archive branches in |
| # case they already got updated by a previous (possibly failed?) merge, but |
| # do not force push to the real master-chromium branch as this could erase |
| # downstream changes. |
| refspecs = ['%s:%s' % (src, target), |
| '+%s:refs/archive/chromium-%s' % (src, version)] |
| if target == 'master-chromium': |
| refspecs.insert(0, '+%s:master-chromium-merge' % src) |
| for refspec in refspecs: |
| logging.debug('Pushing to server (%s) ...' % refspec) |
| for path in merge_common.ALL_PROJECTS: |
| if path in merge_common.PROJECTS_WITH_FLAT_HISTORY: |
| remote = 'history' |
| else: |
| remote = 'goog' |
| logging.debug('Pushing %s', path) |
| dest_dir = os.path.join(merge_common.REPOSITORY_ROOT, path) |
| merge_common.GetCommandStdout(['git', 'push', remote, refspec], |
| cwd=dest_dir) |
| |
| |
| def main(): |
| parser = optparse.OptionParser(usage='%prog [options]') |
| parser.epilog = ('Takes a snapshot of the Chromium tree at the specified ' |
| 'Chromium SVN revision and merges it into this repository. ' |
| 'Paths marked as excluded for license reasons are removed ' |
| 'as part of the merge. Also generates Android makefiles and ' |
| 'generates a top-level NOTICE file suitable for use in the ' |
| 'Android build.') |
| parser.add_option( |
| '', '--svn_revision', |
| default=None, |
| help=('Merge to the specified chromium SVN revision, rather than using ' |
| 'the current LKGR. Can also pass HEAD to merge from tip of tree. ' |
| 'Only one of svn_revision, sha1 and release should be specified')) |
| parser.add_option( |
| '', '--sha1', |
| default=None, |
| help=('Merge to the specified chromium sha1 revision from ' + SRC_GIT_BRANCH |
| + ' branch, rather than using the current LKGR. Only one of' |
| 'svn_revision, sha1 and release should be specified.')) |
| parser.add_option( |
| '', '--release', |
| default=None, |
| help=('Merge to the specified chromium release buildspec (e.g. ' |
| '"30.0.1599.20"). Only one of svn_revision, sha1 and release ' |
| 'should be specified.')) |
| parser.add_option( |
| '', '--buildspec_url', |
| default=None, |
| help=('Git URL for buildspec repository.')) |
| parser.add_option( |
| '', '--target', |
| default='master-chromium', metavar='BRANCH', |
| help=('Target branch to push to. Defaults to master-chromium.')) |
| parser.add_option( |
| '', '--push', |
| default=False, action='store_true', |
| help=('Push the result of a previous merge to the server. Note ' |
| 'svn_revision must be given.')) |
| parser.add_option( |
| '', '--get_lkgr', |
| default=False, action='store_true', |
| help=('Just print the current LKGR on stdout and exit.')) |
| parser.add_option( |
| '', '--get_head', |
| default=False, action='store_true', |
| help=('Just print the current HEAD revision on stdout and exit.')) |
| parser.add_option( |
| '', '--unattended', |
| default=False, action='store_true', |
| help=('Run in unattended mode.')) |
| parser.add_option( |
| '', '--no_changes_exit', |
| default=0, type='int', |
| help=('Exit code to use if there are no changes to merge, for scripts.')) |
| (options, args) = parser.parse_args() |
| if args: |
| parser.print_help() |
| return 1 |
| |
| if 'ANDROID_BUILD_TOP' not in os.environ: |
| print >>sys.stderr, 'You need to run the Android envsetup.sh and lunch.' |
| return 1 |
| |
| logging.basicConfig(format='%(message)s', level=logging.DEBUG, |
| stream=sys.stdout) |
| |
| if options.get_lkgr: |
| print GetLKGR() |
| elif options.get_head: |
| logging.disable(logging.CRITICAL) # Prevent log messages |
| print GetHEAD() |
| elif options.push: |
| if options.release: |
| Push(options.release, options.target) |
| elif options.svn_revision: |
| Push(options.svn_revision, options.target) |
| else: |
| print >>sys.stderr, 'You need to pass the version to push.' |
| return 1 |
| else: |
| if not Snapshot(options.svn_revision, options.sha1, options.release, |
| options.target, options.unattended, options.buildspec_url): |
| return options.no_changes_exit |
| |
| return 0 |
| |
| if __name__ == '__main__': |
| sys.exit(main()) |