| #!/usr/bin/env fuchsia-vendored-python |
| # Copyright 2019 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 argparse |
| import datetime |
| from functools import total_ordering |
| import json |
| import logging |
| import os |
| import re |
| import subprocess |
| import sys |
| import tempfile |
| |
| tool_description = """Move directory to new location in source tree |
| |
| |
| This is a tool to move a directory of source code from one place in the |
| Fuchsia Platform Source Tree to another location in a minimally invasive |
| manner. This script attempts to identify references to the original location |
| and updates them or generates artifacts that transparently forward to the |
| new location. To use: |
| |
| 1.) Check out origin/main and configure a build in the default build |
| directory with 'fx set' |
| 2.) Run 'scripts/move/source/move_source.py <source> <dest>'. This will |
| generate a new git branch and create a commit with a description of what the |
| tool did. |
| 3.) Examine the references that the tool reports that it could not handle |
| and fix up as needed with 'git commit -a' |
| 4.) Upload for review |
| |
| """ |
| |
| fuchsia_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) |
| """ |
| Creates a git branch for a move and checks it out. |
| """ |
| |
| |
| def create_branch_for_move(source, dest, dry_run): |
| branch_name = 'move_%s_to_%s' % ( |
| source.replace('/', '_'), dest.replace('/', '_')) |
| |
| run_command( |
| ['git', 'checkout', '-b', branch_name, '--track', 'origin/main'], |
| dry_run) |
| |
| |
| """ |
| Guess if a target is a go library target, which requires a specialized |
| forwarding target type. |
| """ |
| |
| |
| def is_go_library_target(target): |
| return ( |
| target['type'] == 'action' and |
| target['script'] == '//build/go/gen_library_metadata.py') |
| |
| |
| """ |
| Stores information about a forwarding GN target to generate. |
| """ |
| |
| @total_ordering |
| class ForwardingTarget: |
| |
| def __init__(self, label, target): |
| self.label = label |
| self.testonly = target['testonly'] |
| self.is_go_library = is_go_library_target(target) |
| |
| def __repr__(self): |
| return 'ForwardingTarget(%s, %s, %s)' % ( |
| self.label, self.testonly, self.is_go_library) |
| |
| def __str__(self): |
| return '%s testonly %s go_library %s' % ( |
| self.label, self.testonly, self.is_go_library) |
| |
| def __lt__(self, other): |
| return id(self) < id(other) |
| |
| |
| """ |
| Finds all external references to GN targets in the source directory and |
| records information needed to generate forwarding targets. |
| """ |
| |
| |
| def find_referenced_targets(build_graph, source): |
| # List of targets in the source directory tree. Each entry is a tuple |
| # of (string, boolean) |
| targets_in_source = [] |
| |
| # Set of targets (identified by gn label) in the source directory that |
| # are referenced by targets outside the source directory. |
| referenced_targets = set() |
| |
| for label, target in build_graph.items(): |
| if label.startswith('//' + source): |
| targets_in_source.append(ForwardingTarget(label, target)) |
| else: |
| all_deps = target['deps'] |
| if 'public_deps' in target: |
| all_deps = all_deps + target['public_deps'] |
| for dep in all_deps: |
| # Remove the toolchain qualifier, if present |
| if '(' in dep: |
| dep = dep[0:dep.find('(')] |
| if dep.startswith('//' + source): |
| referenced_targets.add(dep) |
| |
| logging.debug('targets_in_source %s' % targets_in_source) |
| logging.debug('referenced_targets %s' % referenced_targets) |
| |
| forwarding_targets = [] |
| # compute forwarding targets to create |
| for target in sorted(targets_in_source): |
| if target.label in referenced_targets: |
| logging.debug('Need to generate forwarding target for %s' % target) |
| forwarding_targets.append(target) |
| |
| return forwarding_targets |
| |
| |
| """ |
| Finds all references to the source path that we don't know how to automatically |
| handle so the user can examine and update as needed. |
| """ |
| |
| |
| def find_unknown_references(source): |
| unknown_references = [] |
| jiri_grep_args = ['jiri', 'grep', source] |
| logging.debug('Running %s' % jiri_grep_args) |
| grep_results = subprocess.check_output(jiri_grep_args, cwd=fuchsia_root) |
| for line in grep_results.splitlines(): |
| if ':' in line: |
| file, match = line.split(':', 1) |
| if not os.path.normpath(file).startswith(source): |
| if '#include' in match: |
| continue |
| if file.endswith('BUILD.gn'): |
| continue |
| unknown_references.append(line) |
| return unknown_references |
| |
| |
| """ |
| Partial information about the contents of a build file. Can be combined with |
| other instances and written out to a file object. |
| """ |
| |
| |
| class PartialBuildFile(): |
| |
| def __init__(self): |
| self.imports = set() |
| self.snippet = '' |
| |
| def merge(self, other): |
| self.imports |= other.imports |
| self.snippet += '\n' + other.snippet |
| |
| def write(self, f): |
| for import_path in sorted(self.imports): |
| f.write(''' |
| import("%s")''' % import_path) |
| f.write(self.snippet) |
| |
| |
| """ |
| Generates a partial build file representing a forwarding target. |
| Returns: |
| |
| - path to BUILD.gn file |
| - partial GN build file contents |
| """ |
| |
| |
| def generate_forwarding_target(target, source, dest): |
| label_path, target_name = target.label.split(':') |
| containing_directory = label_path[2:] |
| build_file_path = os.path.join( |
| fuchsia_root, containing_directory, 'BUILD.gn') |
| imports = set() |
| # compute label relative to directory. This will be the same in the |
| # source and destination |
| relative_label = os.path.relpath(containing_directory, source) |
| |
| dest_path = os.path.normpath(os.path.join(dest, relative_label)) |
| dest_label = '//%s:%s' % (dest_path, target_name) |
| |
| logging.debug( |
| 'relative_label %s dest_label %s target_name %s' % |
| (relative_label, dest_label, target_name)) |
| |
| build = PartialBuildFile() |
| |
| build.snippet = ''' |
| # Do not use this target directly, instead depend on %s.''' % dest_label |
| |
| if target.is_go_library: |
| build.imports.add('//build/go/go_library.gni') |
| build.snippet += ''' |
| go_library("%s") { |
| name = "%s_forwarding_target" |
| |
| deps = [ |
| ''' % (target_name, target_name) |
| |
| else: |
| build.snippet += ''' |
| group("%s") { |
| public_deps = [ |
| ''' % target_name |
| |
| build.snippet += ''' "%s" |
| ] |
| ''' % dest_label |
| |
| if target.testonly: |
| build.snippet += ''' testonly = true |
| ''' |
| |
| build.snippet += '''} |
| ''' |
| |
| return build_file_path, build |
| |
| |
| """ |
| Write out forwarding build rules for a set of forwarding targets. |
| """ |
| |
| |
| def write_forwarding_build_rules(forwarding_targets, source, dest, dry_run): |
| build_files = {} |
| for target in forwarding_targets: |
| path, build = generate_forwarding_target(target, source, dest) |
| if path not in build_files: |
| build_files[path] = PartialBuildFile() |
| |
| build_files[path].merge(build) |
| |
| for path, build in build_files.items(): |
| if not dry_run: |
| if not os.path.exists(path): |
| dest_dir = os.path.dirname(path) |
| if not os.path.exists(dest_dir): |
| os.makedirs(dest_dir) |
| with open(path, 'w') as f: |
| write_copyright_header(f, '#') |
| with open(path, 'a') as f: |
| build.write(f) |
| run_command(['git', 'add', path], dry_run) |
| |
| |
| """ |
| Appends contents to a BUILD.gn file in a given directory. Creates the file |
| if necessary, including copyright headers. Does not format the file it |
| creates or modifies. |
| """ |
| |
| |
| def append_to_gn_file(directory, contents): |
| dest_path = os.path.join(fuchsia_root, directory, 'BUILD.gn') |
| if not os.path.exists(dest_path): |
| dest_dir = os.path.dirname(dest_path) |
| if not os.path.exists(dest_dir): |
| os.makedirs(dest_dir) |
| with open(dest_path, 'w') as f: |
| write_copyright_header(f, '#') |
| with open(dest_path, 'a') as f: |
| f.write(contents) |
| dry_run = False |
| run_command(['git', 'add', dest_path], False) |
| |
| |
| """ |
| Writes a copyright header for the current year into a provided open file. |
| """ |
| |
| |
| def write_copyright_header(f, comment): |
| copyright_header = '''%s Copyright %s The Fuchsia Authors. All rights reserved. |
| %s Use of this source code is governed by a BSD-style license that can be |
| %s found in the LICENSE file. |
| |
| ''' % (comment, datetime.date.today().year, comment, comment) |
| f.write(copyright_header) |
| |
| |
| """ |
| Generates a forwarding header. |
| """ |
| |
| |
| def generate_forwarding_header(header, source, dest, dry_run): |
| header_dir = os.path.dirname(header) |
| relative_path = os.path.normpath(os.path.relpath(header, source)) |
| dest_path = os.path.join(dest, relative_path) |
| |
| logging.debug( |
| 'Generating forwarding header %s pointing to %s' % (header, dest_path)) |
| |
| if dry_run: |
| return |
| |
| if not os.path.exists(header_dir): |
| os.makedirs(header_dir) |
| with open(header, 'w') as f: |
| write_copyright_header(f, '//') |
| # Formatter will generate a proper header guard |
| f.write('#pragma once\n') |
| |
| f.write( |
| ''' |
| // Do not use this header directly, instead use %s. |
| |
| ''' % dest_path) |
| f.write('#include "%s"\n' % dest_path) |
| |
| run_command(['git', 'add', header], dry_run) |
| |
| |
| """ |
| Generates a commit message for the move including general information |
| about the move as well as a list of generated forwarding artifacts. |
| """ |
| |
| |
| def generate_commit_message( |
| source, dest, forwarding_targets, forwarding_headers, change_id): |
| commit_message = '''[%s] Move %s to %s |
| |
| This moves the contents of the directory: |
| %s |
| to the directory: |
| %s |
| |
| to better match Fuchsia's desired source layout: |
| https://fuchsia.googlesource.com/fuchsia/+/HEAD/docs/development/source_code/layout.md |
| |
| ''' % (os.path.basename(dest), source, dest, source, dest) |
| |
| if len(forwarding_targets): |
| commit_message += ''' |
| This commit includes the following forwarding build targets to ease the |
| transition to the new layout: |
| |
| ''' |
| for target in sorted(forwarding_targets): |
| commit_message += ' %s\n' % target.label |
| commit_message += ''' |
| New code should rely on the new paths for these targets. A follow up commit |
| will remove the forwarding targets once all references are updated. |
| ''' |
| |
| if len(forwarding_headers): |
| commit_message += ''' |
| This commit includes the following forwarding headers to ease the |
| transition to the new layout: |
| |
| ''' |
| for header in sorted(forwarding_headers): |
| commit_message += ' %s\n' % header |
| commit_message += ''' |
| |
| New code should rely on the new paths for these headers. A follow up commit |
| will remove the forwarding headers once all references are updated. |
| ''' |
| |
| commit_message += ''' |
| This commit is generated by the script //scripts/move_source/move_source.py and |
| should contain no behavioral changes. |
| |
| Bug: 36063 |
| ''' |
| |
| if change_id: |
| commit_message += ''' |
| Change-Id: %s |
| ''' % change_id |
| return commit_message |
| |
| |
| """ |
| This finds all header files in a source directory that are referenced by |
| files outside the source directory. |
| """ |
| |
| |
| def find_externally_referenced_headers(source): |
| externally_referenced_headers = set() |
| jiri_grep_args = ['jiri', 'grep', '#include .%s' % source] |
| logging.debug('Running %s' % jiri_grep_args) |
| |
| # search for all includes |
| grep_results = subprocess.check_output(jiri_grep_args, cwd=fuchsia_root) |
| for line in grep_results.splitlines(): |
| print(line) |
| file, include = line.split(':', 1) |
| # filter out includes from within the source directory |
| if not os.path.normpath(file).startswith(source): |
| include = include[len('#include "'):-1] |
| externally_referenced_headers.add(include) |
| |
| logging.debug( |
| 'Externally referenced headers: %s' % externally_referenced_headers) |
| return externally_referenced_headers |
| |
| |
| """ |
| This runs 'gn desc' and parses the output to produce a representation of the |
| build graph. |
| """ |
| |
| |
| def extract_build_graph(label_or_pattern='//*'): |
| out_dir = subprocess.check_output(['fx', 'get-build-dir']).strip() |
| |
| args = [ |
| 'fx', 'gn', 'desc', out_dir, label_or_pattern, '--format=json', |
| '--all-toolchains' |
| ] |
| json_build_graph = subprocess.check_output(args) |
| return json.loads(json_build_graph) |
| |
| |
| """ |
| Moves everything from the directory 'source' to 'dest' |
| """ |
| |
| |
| def move_directory(source, dest, dry_run, repository=fuchsia_root): |
| # git mv requires the parent of the destination directory to exist in |
| # order to move a directory. |
| dest_parent = os.path.dirname(dest) |
| dest_parent_abs = os.path.join(repository, dest_parent) |
| if not os.path.exists(dest_parent_abs): |
| os.makedirs(dest_parent_abs) |
| run_command(['git', 'mv', source, dest], dry_run, cwd=repository) |
| |
| |
| """ |
| Updates all references to 'source' in files in the directory 'dest'. |
| """ |
| |
| |
| def update_all_references(source, dest, dry_run): |
| for dirpath, dirnames, filenames in os.walk(dest): |
| for name in filenames: |
| filepath = os.path.join(dirpath, name) |
| logging.debug( |
| 'converting %s to %s in %s' % (source, dest, filepath)) |
| |
| # On a dry run, verify that the input_file can be read |
| # On a normal run, read the input, write with new references |
| # to a temp file, and then swap the temp file over the input file |
| with open(filepath, 'r') as input_file: |
| lines = input_file.readlines() |
| if not dry_run: |
| filepath_temp = filepath + ".temp" |
| with open(filepath_temp, 'w') as output_file: |
| for line in lines: |
| output_file.write(re.sub(source, dest, line)) |
| os.rename(filepath_temp, filepath) |
| |
| |
| """ |
| Make a git commit with the provided commit message. |
| """ |
| |
| |
| def commit_with_message(commit_message, dry_run, no_commit): |
| with tempfile.NamedTemporaryFile(delete=False) as commit_message_file: |
| commit_message_file.write(commit_message) |
| if not no_commit: |
| run_command( |
| ['git', 'commit', '-a', |
| '--file=%s' % commit_message_file.name], dry_run) |
| os.remove(commit_message_file.name) |
| |
| |
| """ |
| Logs a command and then runs it in the Fuchsia root if dry_run is false. |
| """ |
| |
| |
| def run_command(command, dry_run, cwd=fuchsia_root): |
| logging.debug('Running %s in cwd %s' % (command, cwd)) |
| if not dry_run: |
| subprocess.check_call(command, cwd=cwd) |
| |
| |
| def main(): |
| parser = argparse.ArgumentParser( |
| description=tool_description, |
| formatter_class=argparse.RawDescriptionHelpFormatter) |
| |
| parser.add_argument('source', help='Source path') |
| parser.add_argument('dest', help='Destination path') |
| parser.add_argument( |
| '--no-branch', |
| action='store_true', |
| help='Do not create a git branch for this move') |
| parser.add_argument( |
| '--no-commit', |
| action='store_true', |
| help='Do not create a git commit for this move') |
| parser.add_argument( |
| '--change-id', |
| help='Change-Id value to use in generated commit. ' + |
| 'Use this to generate new commits associated with ' + |
| 'an existing review') |
| parser.add_argument( |
| '--dry-run', |
| '-n', |
| action='store_true', |
| help='Dry run - log commands, but do not modify tree') |
| parser.add_argument( |
| '--verbose', |
| '-v', |
| action='store_true', |
| help='Enable verbose debug logging') |
| |
| args = parser.parse_args() |
| |
| if args.verbose: |
| logging.getLogger().setLevel(logging.DEBUG) |
| |
| source = os.path.normpath(args.source) |
| source_abs = os.path.join(fuchsia_root, source) |
| dest = os.path.normpath(args.dest) |
| dest_abs = os.path.join(fuchsia_root, args.dest) |
| |
| if not os.path.exists(source): |
| print('Source path %s does not exist within source tree' % source) |
| return 1 |
| |
| if os.path.exists(dest): |
| print('Destination path %s already exists' % dest) |
| return 1 |
| |
| # create fresh branch |
| if not args.no_branch: |
| create_branch_for_move(source, dest, args.dry_run) |
| |
| # query build graph |
| build_graph = extract_build_graph() |
| |
| # find forwarding targets to create |
| forwarding_targets = find_referenced_targets(build_graph, source) |
| |
| # Set of forwarding headers to generate, identified by path |
| forwarding_headers = find_externally_referenced_headers(source) |
| |
| # Libraries in garnet/public/lib use //garnet/public:config which adds |
| # //garnet/public to the include path, so includes will look like |
| # #include <lib/foo/...>. Look for includes of that type as well, and |
| # generate forwarding headers as if these were fully qualified includes |
| # if that header exists in garnet/public/lib |
| garnet_public_prefix = 'garnet/public/' |
| if source.startswith(garnet_public_prefix): |
| relative_source = source[len(garnet_public_prefix):] |
| relative_headers = find_externally_referenced_headers(relative_source) |
| for relative_header in relative_headers: |
| relative_path = garnet_public_prefix + relative_header |
| if os.path.exists(os.path.join(fuchsia_root, relative_path)): |
| forwarding_headers.add(garnet_public_prefix + relative_header) |
| |
| # search for other references to old path |
| other_references = find_unknown_references(source) |
| |
| # git mv to destination |
| move_directory(source, dest, args.dry_run) |
| |
| # generate forwarding targets |
| write_forwarding_build_rules(forwarding_targets, source, dest, args.dry_run) |
| |
| # generate forwarding headers |
| for header in forwarding_headers: |
| generate_forwarding_header(header, source, dest, args.dry_run) |
| |
| # update references in destination directory |
| update_all_references(source, dest, args.dry_run) |
| |
| # format code |
| run_command(['fx', 'format-code'], args.dry_run) |
| |
| # generate commit message |
| commit_message = generate_commit_message( |
| source, dest, forwarding_targets, forwarding_headers, args.change_id) |
| |
| logging.debug(commit_message) |
| |
| # commit with message |
| commit_with_message(commit_message, args.dry_run, args.no_commit) |
| |
| # log other references |
| if len(other_references): |
| print( |
| '%s references to old location found, please update manually' % |
| len(other_references)) |
| for line in other_references: |
| print(' %s' % line) |
| |
| |
| if __name__ == '__main__': |
| sys.exit(main()) |