summaryrefslogtreecommitdiff
path: root/chromium/tools/merge_to_android.py
blob: 9a2e78dde5e57eb44fec6be424637f9fe8f8e437 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
#!/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 master-chromium to master within the Android tree."""

import logging
import optparse
import os
import re
import shutil
import subprocess
import sys

import merge_common


AUTOGEN_MESSAGE = 'This commit was generated by merge_to_master.py.'
WEBVIEW_PROJECT = 'frameworks/webview'


def _GetAbsPath(project):
  """Returns the full path to a given project (either Chromium or Android)."""
  if project in merge_common.ALL_PROJECTS:
    abs_path = os.path.join(merge_common.REPOSITORY_ROOT, project)
  else:
    abs_path = os.path.join(os.environ['ANDROID_BUILD_TOP'], project)
  if not os.path.exists(abs_path):
    raise merge_common.MergeError('Cannot find path ' + abs_path)
  return abs_path


def _CheckoutSingleProject(project, target_branch):
  """Checks out the tip of the target_branch into a local branch (merge-to-XXX).

  Args:
    project: a Chromium project (., third_party/foo) or frameworks/webview.
    target_branch: name of the target branch (in the goog remote).
  """
  dest_dir = _GetAbsPath(project)
  tracking_branch = 'goog/' + target_branch
  logging.debug('Check out %-45s at %-16s', project, tracking_branch)
  merge_common.GetCommandStdout(['git', 'remote', 'update', 'goog'],
                                cwd=dest_dir)
  merge_common.GetCommandStdout(['git', 'checkout',
                                 '-b', 'merge-to-' + target_branch,
                                 '-t', tracking_branch], cwd=dest_dir)


def _FetchSingleProject(project, remote, remote_ref):
  """Fetches a remote ref for the given project and returns the fetched SHA.

  Args:
    project: a Chromium project (., third_party/foo) or frameworks/webview.
    remote: Git remote name (goog for most projects, history for squashed ones).
    remote_ref: the remote ref to fetch (e.g., refs/archive/chromium-XXX).

  Returns:
    The SHA1 of the FETCH_HEAD.
  """
  dest_dir = _GetAbsPath(project)
  logging.debug('Fetch     %-45s %s:%s', project, remote, remote_ref)
  merge_common.GetCommandStdout(['git', 'fetch', remote, remote_ref],
                                cwd=dest_dir)
  return merge_common.GetCommandStdout(['git', 'rev-parse', 'FETCH_HEAD'],
                                       cwd=dest_dir).strip()


def _MergeSingleProject(project, merge_sha, revision, target_branch, flatten):
  """Merges a single project at a given SHA.

  Args:
    project: a Chromium project (., third_party/foo) or frameworks/webview.
    merge_sha: the SHA to merge.
    revision: Abbrev. commitish in the main Chromium repository.
    target_branch: name of the target branch.
    flatten: True: squash history while merging; False: perform a normal merge.
  """
  dest_dir = _GetAbsPath(project)
  if flatten:
    # Make the previous merges into grafts so we can do a correct merge.
    old_sha = merge_common.GetCommandStdout(['git', 'rev-parse', 'HEAD'],
                                            cwd=dest_dir).strip()
    merge_log = os.path.join(dest_dir, '.merged-revisions')
    if os.path.exists(merge_log):
      shutil.copyfile(merge_log,
                      os.path.join(dest_dir, '.git', 'info', 'grafts'))

  # Early out if there is nothing to merge.
  if not merge_common.GetCommandStdout(['git', 'rev-list', '-1',
                                        'HEAD..' + merge_sha], cwd=dest_dir):
    logging.debug('No new commits to merge in project %s', project)
    return

  logging.debug('Merging project %s (flatten: %s)...', project, flatten)
  merge_cmd = ['git', 'merge', '--no-commit']
  merge_cmd += ['--squash'] if flatten else ['--no-ff']
  merge_cmd += [merge_sha]
  # Merge conflicts cause 'git merge' to return 1, so ignore errors
  merge_common.GetCommandStdout(merge_cmd, cwd=dest_dir, ignore_errors=True)

  if flatten:
    dirs_to_prune = merge_common.PRUNE_WHEN_FLATTENING.get(project, [])
    if dirs_to_prune:
      merge_common.GetCommandStdout(['git', 'rm', '--ignore-unmatch', '-rf'] +
                                    dirs_to_prune, cwd=dest_dir)

  if project in merge_common.ALL_PROJECTS:
    commit_msg = 'Merge from Chromium at DEPS revision %s' % revision
  else:
    commit_msg = 'Merge master-chromium into %s at %s' % (target_branch,
                                                          revision)
  commit_msg += '\n\n' + AUTOGEN_MESSAGE
  merge_common.CheckNoConflictsAndCommitMerge(commit_msg, cwd=dest_dir)

  if flatten:
    # Generate the new grafts file and commit it on top of the merge.
    new_sha = merge_common.GetCommandStdout(['git', 'rev-parse', 'HEAD'],
                                            cwd=dest_dir).strip()
    with open(merge_log, 'a+') as f:
      f.write('%s %s %s\n' % (new_sha, old_sha, merge_sha))
    merge_common.GetCommandStdout(['git', 'add', '.merged-revisions'],
                                  cwd=dest_dir)
    merge_common.GetCommandStdout(
        ['git', 'commit', '-m',
         'Record Chromium merge at DEPS revision %s\n\n%s' %
         (revision, AUTOGEN_MESSAGE)], cwd=dest_dir)


def _IsAncestor(ref1, ref2, cwd):
  """Checks whether ref1 is a ancestor of ref2 in the given Git repo."""
  cmd = ['git', 'merge-base', '--is-ancestor', ref1, ref2]
  ret = subprocess.call(cmd, cwd=cwd)
  if ret == 0:
    return True
  elif ret == 1:
    return False
  else:
    raise merge_common.CommandError(ret, ' '.join(cmd), cwd, 'N/A', 'N/A')


def _MergeChromiumProjects(revision, target_branch, repo_shas=None,
                           force=False):
  """Merges the Chromium projects from master-chromium to target_branch.

  The larger projects' histories are flattened in the process.
  When repo_shas != None, it checks that the SHAs of the projects in the
  archive match exactly the SHAs of the projects in repo.prop.

  Args:
    revision: Abbrev. commitish in the main Chromium repository.
    target_branch: target branch name to merge and push to.
    repo_shas: optional dict. of expected revisions (only for --repo-prop).
    force: True: merge anyways using the SHAs from repo.prop; False: bail out if
                 projects mismatch (archive vs repo.prop).
  """
  # Sync and checkout ToT for all projects (creating the merge-to-XXX branch)
  # and fetch the archive snapshot.
  fetched_shas = {}
  remote_ref = 'refs/archive/chromium-%s' % revision
  for project in merge_common.PROJECTS_WITH_FLAT_HISTORY:
    _CheckoutSingleProject(project, target_branch)
    fetched_shas[project] = _FetchSingleProject(project, 'history', remote_ref)
  for project in merge_common.PROJECTS_WITH_FULL_HISTORY:
    _CheckoutSingleProject(project, target_branch)
    fetched_shas[project] = _FetchSingleProject(project, 'goog', remote_ref)

  if repo_shas:
    project_shas_mismatch = False
    for project, merge_sha in fetched_shas.items():  # the dict can be modified.
      expected_sha = repo_shas.get(project)
      if expected_sha != merge_sha:
        logging.warn('The SHA for project %s specified in the repo.prop (%s) '
                     'and the one in the archive (%s) differ.',
                     project, expected_sha, merge_sha)
        dest_dir = _GetAbsPath(project)
        if expected_sha is None:
          reason = 'cannot find a SHA in the repo.pro for %s' % project
        elif _IsAncestor(merge_sha, expected_sha, cwd=dest_dir):
          reason = 'the SHA in repo.prop is ahead of the SHA in the archive. '
          log_cmd = ['git', 'log', '--oneline', '--graph', '--max-count=10',
                     '%s..%s' % (merge_sha, expected_sha)]
          log_cmd_output = merge_common.GetCommandStdout(log_cmd, cwd=dest_dir)
          reason += 'showing partial log (%s): \n %s' % (' '.join(log_cmd),
                                                         log_cmd_output)
        elif _IsAncestor(expected_sha, merge_sha, cwd=dest_dir):
          reason = 'The SHA is already merged in the archive'
        else:
          reason = 'The project history diverged. Consult your Git historian.'

        project_shas_mismatch = True
        if force:
          logging.debug('Merging the SHA in repo.prop anyways (due to --force)')
          fetched_shas[project] = expected_sha
        else:
          logging.debug('Reason: %s', reason)
    if not force and project_shas_mismatch:
      raise merge_common.MergeError(
          'The revision of some projects in the archive is different from the '
          'one provided in build.prop. See the log for more details. Re-run '
          'with --force to continue.')

  for project in merge_common.PROJECTS_WITH_FLAT_HISTORY:
    _MergeSingleProject(project, fetched_shas[project], revision, target_branch,
                        flatten=True)
  for project in merge_common.PROJECTS_WITH_FULL_HISTORY:
    _MergeSingleProject(project, fetched_shas[project], revision, target_branch,
                        flatten=False)


def _GetNearestUpstreamAbbrevSHA(reference='history/master-chromium'):
  """Returns the abbrev. upstream SHA which closest to the given reference."""
  logging.debug('Getting upstream SHA for %s...', reference)
  merge_common.GetCommandStdout(['git', 'remote', 'update', 'history'])
  upstream_commit = merge_common.Abbrev(merge_common.GetCommandStdout([
      'git', 'merge-base', 'history/upstream-master', reference]))

  # Pedantic check: look for the existence of a merge commit which contains the
  # |upstream_commit| in its message and is its children.
  merge_parents = merge_common.GetCommandStdout([
      'git', 'rev-list', reference, '--grep', upstream_commit, '--merges',
      '--parents', '-1'])
  if upstream_commit not in merge_parents:
    raise merge_common.MergeError(
        'Found upstream commit %s, but the merge child (%s) could not be found '
        'or is not a parent of the upstream SHA')
  logging.debug('Found nearest Chromium revision %s', upstream_commit)
  return upstream_commit


def _MergeWithRepoProp(repo_prop_file, target_branch, force):
  """Performs a merge using a repo.prop file (from Android build waterfall).

  This does NOT merge (unless forced with force=True) the pinned
  revisions in repo.prop, as a repo.prop can snapshot an intermediate state
  (between two automerger cycles). Instead, this looks up the archived snapshot
  (generated by the chromium->master-chromium auto-merger) which is closest to
  the given repo.prop (following the main Chromium project) and merges that one.
  If the projects revisions don't match, it fails with detailed error messages.

  Args:
    repo_prop_file: Path to a downloaded repo.prop file.
    target_branch: name of the target branch to merget to.
    force: ignores the aforementioned check and merged anyways.
  """
  chromium_sha = None
  webview_sha = None
  repo_shas = {}  # 'project/path' -> 'sha'
  with open(repo_prop_file) as prop:
    for line in prop:
      repo, sha = line.split()
      # Translate the Android repo paths into the relative project paths used in
      # merge_common (e.g., platform/external/chromium_org/foo -> foo).
      m = (
          re.match(r'^platform/(frameworks/.+)$', repo) or
          re.match(r'^platform/external/chromium_org/?(.*?)(-history)?$', repo))
      if m:
        project = m.group(1) if m.group(1) else '.'  # '.' = Main project.
        repo_shas[project] = sha

  chromium_sha = repo_shas.get('.')
  webview_sha = repo_shas.get(WEBVIEW_PROJECT)
  if not chromium_sha or not webview_sha:
    raise merge_common.MergeError('SHAs for projects not found; '
                                  'invalid build.prop?')

  # Check that the revisions in repo.prop and the on in the archive match.
  archived_chromium_revision = _GetNearestUpstreamAbbrevSHA(chromium_sha)
  logging.info('Merging Chromium at %s and WebView at %s',
               archived_chromium_revision, webview_sha)
  _MergeChromiumProjects(archived_chromium_revision, target_branch, repo_shas,
                         force)

  _CheckoutSingleProject(WEBVIEW_PROJECT, target_branch)
  _MergeSingleProject(WEBVIEW_PROJECT, webview_sha,
                      archived_chromium_revision, target_branch, flatten=False)


def Push(target_branch):
  """Push the finished snapshot to the Android repository.

  Creates first a CL for frameworks/webview (if the merge-to-XXX branch exists)
  then wait for user confirmation and pushes the Chromium merges. This is to
  give an opportunity to get a +2 for  frameworks/webview and then push both
  frameworks/webview and the Chromium projects atomically(ish).

  Args:
    target_branch: name of the target branch (in the goog remote).
  """
  merge_branch = 'merge-to-%s' % target_branch

  # Create a Gerrit CL for the frameworks/webview project (if needed).
  dest_dir = _GetAbsPath(WEBVIEW_PROJECT)
  did_upload_webview_cl = False
  if merge_common.GetCommandStdout(['git', 'branch', '--list', merge_branch],
                                   cwd=dest_dir):
    # Check that there was actually something to merge.
    merge_range = 'goog/%s..%s' % (target_branch, merge_branch)
    if merge_common.GetCommandStdout(['git', 'rev-list', '-1', merge_range],
                                     cwd=dest_dir):
      logging.info('Uploading a merge CL for %s...', WEBVIEW_PROJECT)
      refspec = '%s:refs/for/%s' % (merge_branch, target_branch)
      upload = merge_common.GetCommandStdout(['git', 'push', 'goog', refspec],
                                             cwd=dest_dir)
      logging.info(upload)
      did_upload_webview_cl = True

  prompt_msg = 'About push the Chromium projects merge. '
  if not did_upload_webview_cl:
    logging.info('No merge CL needed for %s.', WEBVIEW_PROJECT)
  else:
    prompt_msg += ('At this point you should have the CL +2-ed and merge it '
                   'together with this push.')
  prompt_msg += '\nPress "y" to continue: '
  if raw_input(prompt_msg) != 'y':
    logging.warn('Push aborted by the user!')
    return

  logging.debug('Pushing Chromium projects to %s ...', target_branch)
  refspec = '%s:%s' % (merge_branch, target_branch)
  for path in merge_common.ALL_PROJECTS:
    logging.debug('Pushing %s', path)
    dest_dir = _GetAbsPath(path)
    # Delete the graft before pushing otherwise git will attempt to push all the
    # grafted-in objects to the server as well as the ones we want.
    graftfile = os.path.join(dest_dir, '.git', 'info', 'grafts')
    if os.path.exists(graftfile):
      os.remove(graftfile)
    merge_common.GetCommandStdout(['git', 'push', 'goog', refspec],
                                  cwd=dest_dir)


def main():
  parser = optparse.OptionParser(usage='%prog [options]')
  parser.epilog = ('Takes the current master-chromium branch of the Chromium '
                   'projects in Android and merges them into master to publish '
                   'them.')
  parser.add_option(
      '', '--revision',
      default=None,
      help=('Merge to the specified archived master-chromium revision (abbrev. '
            'SHA or release version) rather than using HEAD. e.g., '
            '--revision=a1b2c3d4e5f6 or --revision=38.0.2125.24'))
  parser.add_option(
      '', '--repo-prop',
      default=None, metavar='FILE',
      help=('Merge to the revisions specified in this repo.prop file.'))
  parser.add_option(
      '', '--force',
      default=False, action='store_true',
      help=('Skip history checks and merged anyways (only for --repo-prop).'))
  parser.add_option(
      '', '--push',
      default=False, action='store_true',
      help=('Push the result of a previous merge to the server.'))
  parser.add_option(
      '', '--target',
      default='master', metavar='BRANCH',
      help=('Target branch to push to. Defaults to master.'))
  (options, args) = parser.parse_args()
  if args:
    parser.print_help()
    return 1

  logging.basicConfig(format='%(message)s', level=logging.DEBUG,
                      stream=sys.stdout)

  if options.push:
    Push(options.target)
  elif options.repo_prop:
    _MergeWithRepoProp(os.path.expanduser(options.repo_prop),
                       options.target, options.force)
  elif options.revision:
    _MergeChromiumProjects(options.revision, options.target)
  else:
    first_upstream_sha = _GetNearestUpstreamAbbrevSHA()
    _MergeChromiumProjects(first_upstream_sha, options.target)

  return 0

if __name__ == '__main__':
  sys.exit(main())