blob: 4f0aefd4d1b43312ed556ff6bd21dc8ddc68d412 [file] [log] [blame]
#!/usr/bin/python
#
# Copyright (C) 2021 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.
"""
Merges upstream files to ojluni. This is done by using git to perform a 3-way
merge between the current (base) upstream version, ojluni and the new (target)
upstream version. The 3-way merge is needed because ojluni sometimes contains
some Android-specific changes from the upstream version.
This tool is for libcore maintenance; if you're not maintaining libcore,
you won't need it (and might not have access to some of the instructions
below).
The naming of the repositories (expected, ojluni, 7u40, 8u121-b13,
9b113+, 9+181) is based on the directory name where corresponding
snapshots are stored when following the instructions at
http://go/libcore-o-verify
This script tries to preserve Android changes to upstream code when moving to a
newer version.
All the work is made in a new directory which is initialized as a git
repository. An example of the repository structure, where an update is made
from version 9b113+ to 11+28, would be:
* 5593705 (HEAD -> main) Merge branch 'ojluni'
|\
| * 2effe03 (ojluni) Ojluni commit
* | 1bef5f3 Target commit (11+28)
|/
* 9ae2fbf Base commit (9b113+)
The conflicts during the merge get resolved by git whenever possible. However,
sometimes there are conflicts that need to be resolved manually. If that is the
case, the script will terminate to allow for the resolving. Once the user has
resolved the conflicts, they should rerun the script with the --continue
option.
Once the merge is complete, the script will copy the merged version back to
ojluni within the $ANDROID_BUILD_TOP location.
For the script to run correctly, it needs the following environment variables
defined:
- OJLUNI_UPSTREAMS
- ANDROID_BUILD_TOP
Possible uses:
To merge in changes from a newer version of the upstream using a default
working dir created in /tmp:
merge-from-upstream -f expected -t 11+28 java/util/concurrent
To merge in changes from a newer version of the upstream using a custom
working dir:
merge-from-upstream -f expected -t 11+28 \
-d $HOME/tmp/ojluni-merge java/util/concurrent
To merge in changes for a single file:
merge-from-upstream -f 9b113+ -t 11+28 \
java/util/concurrent/atomic/AtomicInteger.java
To merge in changes, using a custom folder, that require conflict resolution:
merge-from-upstream -f expected -t 11+28 \
-d $HOME/tmp/ojluni-merge \
java/util/concurrent
<manually resolve conflicts and add them to git staging>
merge-from-upstream --continue \
-d $HOME/tmp/ojluni-merge java/util/concurrent
"""
import argparse
import os
import os.path
import subprocess
import sys
import shutil
def printerr(msg):
sys.stderr.write(msg + "\r\n")
def user_check(msg):
choice = str(input(msg + " [y/N] ")).strip().lower()
if choice[:1] == 'y':
return True
return False
def check_env_vars():
keys = [
'OJLUNI_UPSTREAMS',
'ANDROID_BUILD_TOP',
]
result = True
for key in keys:
if key not in os.environ:
printerr("Unable to run, you must have {} defined".format(key))
result = False
return result
def get_upstream_path(version, rel_path):
upstreams = os.environ['OJLUNI_UPSTREAMS']
return '{}/{}/{}'.format(upstreams, version, rel_path)
def get_ojluni_path(rel_path):
android_build_top = os.environ['ANDROID_BUILD_TOP']
return '{}/libcore/ojluni/src/main/java/{}'.format(
android_build_top, rel_path)
def make_copy(src, dst):
print("Copy " + src + " -> " + dst)
if os.path.isfile(src):
if os.path.exists(dst) and os.path.isfile(dst):
os.remove(dst)
shutil.copy(src, dst)
else:
shutil.copytree(src, dst, dirs_exist_ok=True)
class Repo:
def __init__(self, dir):
self.dir = dir
def init(self):
if 0 != subprocess.call(['git', 'init', '-b', 'main', self.dir]):
raise RuntimeError(
"Unable to initialize working git repository.")
subprocess.call(['git', '-C', self.dir,
'config', 'rerere.enabled', 'true'])
def commit_all(self, id, msg):
if 0 != subprocess.call(['git', '-C', self.dir, 'add', '*']):
raise RuntimeError("Unable to add the {} files.".format(id))
if 0 != subprocess.call(['git', '-C', self.dir, 'commit',
'-m', msg]):
raise RuntimeError("Unable to commit the {} files.".format(id))
def checkout_branch(self, branch, is_new=False):
cmd = ['git', '-C', self.dir, 'checkout']
if is_new:
cmd.append('-b')
cmd.append(branch)
if 0 != subprocess.call(cmd):
raise RuntimeError("Unable to checkout the {} branch."
.format(branch))
def merge(self, branch):
"""
Tries to merge in a branch and returns True if the merge commit has
been created. If there are conflicts to be resolved, this returns
False.
"""
if 0 == subprocess.call(['git', '-C', self.dir,
'merge', branch, '--no-edit']):
return True
if not self.is_merging():
raise RuntimeError("Unable to run merge for the {} branch."
.format(branch))
subprocess.call(['git', '-C', self.dir, 'rerere'])
return False
def check_resolved_from_cache(self):
"""
Checks if some conflicts have been resolved by the git rerere tool. The
tool only applies the previous resolution, but does not mark the file
as resolved afterwards. Therefore this function will go through the
unresolved files and see if there are outstanding conflicts. If all
conflicts have been resolved, the file gets stages.
Returns True if all conflicts are resolved, False otherwise.
"""
# git diff --check will exit with error if there are conflicts to be
# resolved, therefore we need to use check=False option to avoid an
# exception to be raised
conflict_markers = subprocess.run(['git', '-C', self.dir,
'diff', '--check'],
stdout=subprocess.PIPE,
check=False).stdout
conflicts = subprocess.check_output(['git', '-C', self.dir, 'diff',
'--name-only', '--diff-filter=U'])
for filename in conflicts.splitlines():
if conflict_markers.find(filename) != -1:
print("{} still has conflicts, please resolve manually".
format(filename))
else:
print("{} has been resolved, staging it".format(filename))
subprocess.call(['git', '-C', self.dir, 'add', filename])
return not self.has_conflicts()
def has_changes(self):
result = subprocess.check_output(['git', '-C', self.dir, 'status',
'--porcelain'])
return len(result) != 0
def has_conflicts(self):
conflicts = subprocess.check_output(['git', '-C', self.dir, 'diff',
'--name-only', '--diff-filter=U'])
return len(conflicts) != 0
def is_merging(self):
return 0 == subprocess.call(['git', '-C', self.dir, 'rev-parse',
'-q', '--verify', 'MERGE_HEAD'],
stdout=subprocess.DEVNULL)
def complete_merge(self):
print("Completing merge in {}".format(self.dir))
subprocess.call(['git', '-C', self.dir, 'rerere'])
if 0 != subprocess.call(['git', '-C', self.dir,
'commit', '--no-edit']):
raise RuntimeError("Unable to complete the merge in {}."
.format(self.dir))
if self.is_merging():
raise RuntimeError(
"Merging in {} is not complete".format(self.dir))
def load_resolve_files(self, resolve_dir):
print("Loading resolve files from {}".format(resolve_dir))
if not os.path.lexists(resolve_dir):
print("Resolve dir {} not found, no resolutions will be used"
.format(resolve_dir))
return
make_copy(resolve_dir, self.dir + "/.git/rr-cache")
def save_resolve_files(self, resolve_dir):
print("Saving resolve files to {}".format(resolve_dir))
if not os.path.lexists(resolve_dir):
os.makedirs(resolve_dir)
make_copy(self.dir + "/.git/rr-cache", resolve_dir)
class Merger:
def __init__(self, repo_dir, rel_path, resolve_dir):
self.repo = Repo(repo_dir)
# Have all the source files copied inside a src dir, so we don't have
# any issue with copying back the .git dir
self.working_dir = repo_dir + "/src"
self.rel_path = rel_path
self.resolve_dir = resolve_dir
def create_working_dir(self):
if os.path.lexists(self.repo.dir):
if not user_check(
'{} already exists. Can it be removed?'
.format(self.repo.dir)):
raise RuntimeError(
'Will not remove {}. Consider using another '
'working dir'.format(self.repo.dir))
try:
shutil.rmtree(self.repo.dir)
except OSError:
printerr("Unable to delete {}.".format(self.repo.dir))
raise
os.makedirs(self.working_dir)
self.repo.init()
if self.resolve_dir is not None:
self.repo.load_resolve_files(self.resolve_dir)
def copy_upstream_files(self, version, msg):
full_path = get_upstream_path(version, self.rel_path)
make_copy(full_path, self.working_dir)
self.repo.commit_all(version, msg)
def copy_base_files(self, base_version):
self.copy_upstream_files(base_version,
'Base commit ({})'.format(base_version))
def copy_target_files(self, target_version):
self.copy_upstream_files(target_version,
'Target commit ({})'.format(target_version))
def copy_ojluni_files(self):
full_path = get_ojluni_path(self.rel_path)
make_copy(full_path, self.working_dir)
if self.repo.has_changes():
self.repo.commit_all('ojluni', 'Ojluni commit')
return True
else:
return False
def run_ojluni_merge(self):
if self.repo.merge('ojluni'):
return
if self.repo.check_resolved_from_cache():
self.repo.complete_merge()
return
raise RuntimeError('\r\nThere are conflicts to be resolved.'
'\r\nManually merge the changes and rerun '
'this script with --continue')
def copy_back_to_ojluni(self):
# Save any resolutions that were made for future reuse
if self.resolve_dir is not None:
self.repo.save_resolve_files(self.resolve_dir)
src_path = self.working_dir
dst_path = get_ojluni_path(self.rel_path)
if os.path.isfile(dst_path):
src_path += '/' + os.path.basename(self.rel_path)
make_copy(src_path, dst_path)
def run(self, base_version, target_version):
print("Merging {} from {} into ojluni (based on {}). "
"Using {} as working dir."
.format(self.rel_path, target_version,
base_version, self.repo.dir))
self.create_working_dir()
self.copy_base_files(base_version)
# The ojluni code should be added in its own branch. This is to make
# Git perform the 3-way merge once a commit is added with the latest
# upstream code.
self.repo.checkout_branch('ojluni', is_new=True)
merge_needed = self.copy_ojluni_files()
self.repo.checkout_branch('main')
self.copy_target_files(target_version)
if merge_needed:
# Runs the merge in the working directory, if some conflicts need
# to be resolved manually, then an exception is raised which will
# terminate the script, informing the user that manual intervention
# is needed.
self.run_ojluni_merge()
else:
print("No merging needed as there were no "
"Android-specific changes, forwarding to new version ({})"
.format(target_version))
self.copy_back_to_ojluni()
def complete_existing_run(self):
if self.repo.is_merging():
self.repo.complete_merge()
self.copy_back_to_ojluni()
def main():
if not check_env_vars():
return
upstreams = os.environ['OJLUNI_UPSTREAMS']
repositories = sorted(
[d for d in os.listdir(upstreams)
if os.path.isdir(os.path.join(upstreams, d))]
)
parser = argparse.ArgumentParser(
description='''
Merge upstream files from ${OJLUNI_UPSTREAMS} to libcore/ojluni.
Needs the base (from) repository as well as the target (to) repository.
Repositories can be chosen from:
''' + ' '.join(repositories) + '.',
# include default values in help
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument('-f', '--from', default='expected',
choices=repositories,
dest='base',
help='Repository on which the requested ojluni '
'files are based.')
parser.add_argument('-t', '--to',
choices=repositories,
dest='target',
help='Repository to which the requested ojluni '
'files will be updated.')
parser.add_argument('-d', '--work-dir', default='/tmp/ojluni-merge',
help='Path where the merge will be performed. '
'Any existing files in the path will be removed')
parser.add_argument('-r', '--resolve-dir', default=None,
dest='resolve_dir',
help='Path where the git resolutions are cached. '
'By default, no cache is used.')
parser.add_argument('--continue', action='store_true', dest='proceed',
help='Flag to specify after merge conflicts '
'are resolved')
parser.add_argument('rel_path', nargs=1, metavar='<relative_path>',
help='File to merge: a relative path below '
'libcore/ojluni/ which could point to '
'a file or folder.')
args = parser.parse_args()
try:
merger = Merger(args.work_dir, args.rel_path[0], args.resolve_dir)
if args.proceed:
merger.complete_existing_run()
else:
if args.target is None:
raise RuntimeError('Please specify the target upstream '
'version using the -t/--to argument')
merger.run(args.base, args.target)
except Exception as e:
printerr(str(e))
if __name__ == "__main__":
main()