| #!/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()) |