| #!/usr/bin/env python3 | 
 |  | 
 | # Copyright 2020 The Fuchsia 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 json | 
 | import os | 
 | import re | 
 | import subprocess | 
 | import sys | 
 | import xml.etree.ElementTree as ET | 
 |  | 
 |  | 
 | class Env(object): | 
 |  | 
 |     def __init__(self, root_dir): | 
 |         self.root_dir = root_dir | 
 |         self.integration_dir = os.path.normpath( | 
 |             os.path.join(root_dir, 'integration')) | 
 |         self.stem_cache = os.path.normpath( | 
 |             os.path.join(root_dir, '.fx-sync-from-stem.cache')) | 
 |  | 
 |  | 
 | def git(args, cwd): | 
 |     return subprocess.check_output(['git'] + args, cwd=cwd).decode() | 
 |  | 
 |  | 
 | def jiri(args, cwd): | 
 |     return subprocess.check_output(['jiri'] + args, cwd=cwd).decode() | 
 |  | 
 |  | 
 | def message(msg): | 
 |     print('sync-from-stem:', msg) | 
 |  | 
 |  | 
 | def reverse_commits(fuchsia_commits_for_integration): | 
 |     """ | 
 |     Convert a map of "integration_revs -> [fuchsia_revs]" to | 
 |     "fuchsia_revs -> integration_revs". | 
 |     """ | 
 |     fuchsia_rev_to_integration = {} | 
 |     for integ, fuch_revs in fuchsia_commits_for_integration.items(): | 
 |         for f in fuch_revs: | 
 |             fuchsia_rev_to_integration[f] = integ | 
 |     return fuchsia_rev_to_integration | 
 |  | 
 |  | 
 | def update_stem_history(env): | 
 |     """ | 
 |     Return a map of "fuchsia_rev -> integration_revs". | 
 |  | 
 |     This is a relatively expensive operation, and requires performing a git | 
 |     fetch of the integration repo and parsing the "stem" file. We cache results | 
 |     in the locations specified by "env" to speed up future calls. | 
 |     """ | 
 |     message('updating in %s' % env.integration_dir) | 
 |     git(['fetch', 'origin', '-q'], cwd=env.integration_dir) | 
 |     git(['fetch', 'origin', '-q'], cwd=env.root_dir) | 
 |     git(['checkout', 'origin/master', '-q'], cwd=env.integration_dir) | 
 |     cur_head = git(['rev-parse', 'HEAD'], cwd=env.integration_dir).strip() | 
 |     message('integration now at %s' % cur_head) | 
 |  | 
 |     data = {} | 
 |     data['integration_commits'] = [] | 
 |     data['head'] = '' | 
 |     data['integration_fuchsia_pairs'] = [] | 
 |     data['fuchsia_commits_for_integration'] = {} | 
 |  | 
 |     # Caches: | 
 |     # - integration commit -> fuchsia/stem commit (ordered by integration | 
 |     #   commit) | 
 |     # | 
 |     # - Then, a cache of the range of fuchsia commits between those, i.e: | 
 |     # | 
 |     # integ0 -> fuchsia0 | 
 |     #        -> fuchsia0-1 | 
 |     #        -> fuchsia0-2 | 
 |     # integ1 -> fuchsia1 | 
 |     # integ2 -> fuchsia2 | 
 |     #        -> fuchsia2-1 | 
 |     #        -> fuchsia2-2 | 
 |     #        -> fuchsia2-3 | 
 |     # integ3 -> fuchsia3 | 
 |     # | 
 |     # - And, a reversed version of above from fuchsia to its integration | 
 |     #   hash. The reversed isn't cached, it's just reversed on load. | 
 |  | 
 |     if os.path.exists(env.stem_cache): | 
 |         data = json.load(open(env.stem_cache, 'r')) | 
 |         if data['head'] == cur_head: | 
 |             # Already up to date. | 
 |             return reverse_commits(data['fuchsia_commits_for_integration']) | 
 |  | 
 |     if not data['head']: | 
 |         integration_revs_to_update = cur_head | 
 |     else: | 
 |         integration_revs_to_update = data['head'] + '..' + cur_head | 
 |  | 
 |     stem_paths = ['fuchsia/stem', 'stem'] | 
 |     for p in stem_paths: | 
 |         if os.path.exists(os.path.join(env.integration_dir, p)): | 
 |             stem_path = p | 
 |             break | 
 |     else: | 
 |         print('Could not find stem manifest', file=sys.stderr) | 
 |         sys.exit(1) | 
 |  | 
 |     message('getting integration commits from %s' % integration_revs_to_update) | 
 |     # Arbitrarily truncated at the start of 2020 because sync'ing back farther | 
 |     # than that probably won't build for other reasons, and because the stem | 
 |     # file location changed in old revisions, https://fxbug.dev/54384. | 
 |     new_integration_commits = git( | 
 |         [ | 
 |             'log', '--format=%H', '--since=2020-01-01', | 
 |             integration_revs_to_update, '--', stem_path | 
 |         ], | 
 |         cwd=env.integration_dir).split() | 
 |     data['integration_commits'] = ( | 
 |         new_integration_commits + data['integration_commits']) | 
 |     data['head'] = cur_head | 
 |  | 
 |     # Now we have an up-to-date list of all integration commits, along | 
 |     # with new_integration_commits being the ones that have just been | 
 |     # added. Read the manifest for each of the new ones, and then add | 
 |     # those to the integration_fuchsia_pairs. | 
 |  | 
 |     def get_fuchsia_rev_for_integration_commit(ic): | 
 |         manifest_root = ET.fromstring( | 
 |             git(['show', ic + ':' + stem_path], cwd=env.integration_dir)) | 
 |         for project in manifest_root.find('projects'): | 
 |             if project.get('name') == 'fuchsia': | 
 |                 return project.get('revision') | 
 |         print( | 
 |             'Could not find "fuchsia" project in "%s" file at ' | 
 |             'revision %s.' % (stem_path, ic), | 
 |             file=sys.stderr) | 
 |         sys.exit(1) | 
 |  | 
 |     message( | 
 |         'caching fuchsia commits for %d integration commits' % | 
 |         len(new_integration_commits)) | 
 |     new_integration_fuchsia_pairs = [] | 
 |     for i, ic in enumerate(new_integration_commits): | 
 |         print('\r%d/%d' % (i, len(new_integration_commits)), end='') | 
 |         new_integration_fuchsia_pairs.append( | 
 |             (ic, get_fuchsia_rev_for_integration_commit(ic))) | 
 |     print('\r\033[K', end='')  # Clear line. | 
 |     data['integration_fuchsia_pairs'] = ( | 
 |         new_integration_fuchsia_pairs + data['integration_fuchsia_pairs']) | 
 |  | 
 |     message('caching fuchsia commit ranges') | 
 |     # integration_fuchsia_pairs is the first important mapping that's | 
 |     # useful, but we don't know what's between each of the fuchsia | 
 |     # commits. For each of the new integration commits, log from its | 
 |     # fuchsia commit to the preceding fuchsia commit. | 
 |     # For example, if we have integration commits i0, i1, i2, i3, ... | 
 |     # each with corresponding fuchsia commits f0, f1, f2, f3, ... and | 
 |     # the new ones that were just added above were i0 and i1, then we | 
 |     # need to log f0..f1, f1..f2, and f2..f3. | 
 |  | 
 |     # Special case for the nothing-cached-yet case -- we need to from | 
 |     # the last new commit to the newest old commit (per above comment), | 
 |     # but on the first run, there's nothing before the oldest new | 
 |     # commit. We don't really care about syncing that far back anyway, | 
 |     # so just drop that last one. | 
 |     last_num = min( | 
 |         len(new_integration_fuchsia_pairs), | 
 |         len(data['integration_fuchsia_pairs']) - 1) | 
 |     for i in range(last_num): | 
 |         cur = data['integration_fuchsia_pairs'][i] | 
 |         older = data['integration_fuchsia_pairs'][i + 1] | 
 |         fuchsia_cur = cur[1] | 
 |         fuchsia_older = older[1] | 
 |         print('\r%d/%d' % (i, len(new_integration_fuchsia_pairs)), end='') | 
 |         fuchsia_revs = git( | 
 |             ['log', '--format=%H', fuchsia_older + '..' + fuchsia_cur], | 
 |             cwd=env.root_dir).split() | 
 |         data['fuchsia_commits_for_integration'][cur[0]] = fuchsia_revs | 
 |     print('\r\033[K', end='')  # Clear line. | 
 |  | 
 |     json.dump(data, open(env.stem_cache, 'w')) | 
 |     return reverse_commits(data['fuchsia_commits_for_integration']) | 
 |  | 
 |  | 
 | def set_jiri_ignore(env, to): | 
 |     jiri(['project-config', '-ignore=' + to], cwd=env.root_dir) | 
 |  | 
 |  | 
 | def save_and_fix_fuchsia_jiri_project_config(env): | 
 |     to_return = None | 
 |     for line in jiri(['project-config'], cwd=env.root_dir).splitlines()[1:]: | 
 |         if line.startswith('ignore:'): | 
 |             left, _, right = line.partition(': ') | 
 |             to_return = right.strip() | 
 |             break | 
 |     else: | 
 |         print( | 
 |             'Could not find current "ignore" value in jiri project-config', | 
 |             file=sys.stderr) | 
 |         sys.exit(1) | 
 |     set_jiri_ignore(env, 'true') | 
 |     return to_return | 
 |  | 
 |  | 
 | def extract_and_output_rebase_warnings(log): | 
 |     """Look for specific rebase warning output from jiri. If rebase output | 
 |     is found, output a message about each, and return False, otherwise True. | 
 |  | 
 |     The integration repo is ignored as it will always be pinned to our | 
 |     sync branch. | 
 |  | 
 | # Output and fail if we see the rebase note. | 
 | >>> extract_and_output_rebase_warnings('''blah blah blah | 
 | ... and then some | 
 | ... [10:45:14.203] WARN: For Project "project/somename", branch "muhbranch" does not track any remote branch. | 
 | ... To rebase it update with -rebase-untracked flag, or to rebase it manually run | 
 | ... WE WANT TO RUN THIS LINE | 
 | ... then more stuff down here that | 
 | ... we don't want | 
 | ... ''') | 
 | sync-from-stem: muhbranch in project/somename has no upstream, to rebase: | 
 | sync-from-stem:     WE WANT TO RUN THIS LINE | 
 | False | 
 |  | 
 | # But ignore all the normal goop, and don't fail. | 
 | >>> extract_and_output_rebase_warnings('''[12:09:05.864] Updating all projects | 
 | ... [12:09:14.529] WARN: 1 project(s) is/are marked to be deleted. Run 'jiri update -gc' to delete them. | 
 | ... Or run 'jiri update -v' or 'jiri status -d' to see the list of projects. | 
 | ... | 
 | ... [12:09:14.611] WARN: For Project "integration", branch "lock_at_d3d488db58e068abf60750b94e4e82f8fdc9b57c" does not track any remote branch. | 
 | ... To rebase it update with -rebase-untracked flag, or to rebase it manually run | 
 | ... git -C "../integration" checkout lock_at_d3d488db58e068abf60750b94e4e82f8fdc9b57c && git -C "../integration" rebase e2a923d06860f7a82ad70c429e14328530f41f7e | 
 | ... | 
 | ... PROGRESS: Fetching CIPD packages | 
 | ... ''') | 
 | True | 
 |  | 
 | """ | 
 |     ret = True | 
 |     non_tracking = re.compile( | 
 |             r'WARN: For Project "(.*)", branch "(.*)" does not track') | 
 |     err = [] | 
 |     capture_lines = 0 | 
 |     for line in log.splitlines(): | 
 |         if capture_lines: | 
 |             if capture_lines == 1: | 
 |                 err.append('    ' + line) | 
 |             capture_lines -= 1 | 
 |             continue | 
 |         mo = non_tracking.search(line) | 
 |         if mo and mo.group(1) != 'integration': | 
 |             err.append('%s in %s has no upstream, to rebase:' % mo.group(2, 1)) | 
 |             capture_lines = 2 | 
 |  | 
 |     for x in err: message(x) | 
 |     return not err | 
 |  | 
 |  | 
 | def to_revision(env, fc, ic): | 
 |     prev_ignore = save_and_fix_fuchsia_jiri_project_config(env) | 
 |     try: | 
 |         message('checking out integration %s...' % ic) | 
 |         git( | 
 |             ['checkout', '-q', '-B', | 
 |              'lock_at_%s' % ic, ic], | 
 |             cwd=env.integration_dir) | 
 |         message('updating dependencies with jiri...') | 
 |         log = jiri(['update', '-local-manifest=true'], cwd=env.root_dir) | 
 |         return extract_and_output_rebase_warnings(log) | 
 |     finally: | 
 |         set_jiri_ignore(env, prev_ignore) | 
 |  | 
 |  | 
 | def find_first_non_local_commit(env): | 
 |     return git(['merge-base', 'HEAD', 'origin/master'], | 
 |                cwd=env.root_dir).strip() | 
 |  | 
 |  | 
 | def abort_if_is_dirty(git_repo): | 
 |     dirty = git(['status', '--porcelain', '--untracked-files=no'], cwd=git_repo) | 
 |     if dirty: | 
 |         print( | 
 |             '%s has uncommitted changes, aborting' % git_repo, file=sys.stderr) | 
 |         sys.exit(1) | 
 |  | 
 |  | 
 | def main(): | 
 |     if len(sys.argv) != 2 or not os.path.isdir(sys.argv[1]): | 
 |         print( | 
 |             '''usage: sync-from-stem.py fuchsia_dir | 
 |  | 
 | Sync integration and deps to a state matching fuchsia.git state. | 
 |  | 
 | 1. Finds the first commit on the current branch that's been integrated | 
 |    upstream (i.e. not your local commits). | 
 | 2. Then, finds the integration repo commit that would include that found | 
 |    commit. | 
 | 3. Syncs other dependencies to match that integration commit. | 
 |  | 
 | In this way, you can checkout, update, rebase, etc. your fuchsia.git | 
 | branches and then use this script (rather than jiri update) to update | 
 | the rest of the tree to match that branch. This allows working as if | 
 | fuchsia.git is the "root" of the tree, even though that data is really | 
 | stored in the integration repo. | 
 | ''', | 
 |             file=sys.stderr) | 
 |         return 1 | 
 |     env = Env(os.path.abspath(sys.argv[1])) | 
 |     abort_if_is_dirty(env.integration_dir) | 
 |     f_to_i = update_stem_history(env) | 
 |  | 
 |     fuchsia_rev = find_first_non_local_commit(env) | 
 |     if fuchsia_rev not in f_to_i: | 
 |         message( | 
 |             'fuchsia rev %s not found in integration, not rolled yet?' % | 
 |             fuchsia_rev) | 
 |         return 1 | 
 |     if not to_revision(env, fuchsia_rev, f_to_i[fuchsia_rev]): | 
 |         return 2 | 
 |     message( | 
 |         'synced integration to %s, which matches fuchsia rev %s' % | 
 |         (f_to_i[fuchsia_rev], fuchsia_rev)) | 
 |     return 0 | 
 |  | 
 |  | 
 | if __name__ == '__main__': | 
 |     sys.exit(main()) |