| #!/usr/bin/env python3 |
| |
| # Copyright 2021 The Chromium OS Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| # This is contrib-quality code: not all functions/classes are |
| # documented. |
| # pylint: disable=import-error |
| # pylint: disable=wildcard-import |
| # pylint: disable=unused-wildcard-import |
| # pylint: disable=import-outside-toplevel |
| # pylint: disable=missing-function-docstring |
| # pylint: disable=input-builtin |
| |
| """Automatic rebase |
| |
| This script automates much of the continuous rebase, which is a process |
| designed for carrying patches from the `living` Chrome OS branch (latest LTS) |
| to newer upstream kernels. |
| |
| See go/cont-rebase for details |
| """ |
| |
| import code |
| import os |
| import re |
| import sys |
| from datetime import datetime |
| import multiprocessing |
| from multiprocessing import Manager |
| import pickle |
| import importlib |
| import sqlite3 |
| import sh |
| from common import executor_io, rebasedb |
| from config import * |
| import rebase_config |
| # the import is not used directly, but helpful if one runs python3 -i rebase.py |
| from mailing import Mailing, load_and_notify # pylint: disable=unused-import |
| from githelpers import * |
| |
| class Logger: |
| """Splits stdout into stdout and a file""" |
| |
| def __init__(self): |
| sh.mkdir('-p', 'log/triage/') |
| ts = datetime.now().strftime('%d-%m-%Y_%H-%M-%S') |
| filename = ts + '.log' |
| if os.path.exists('log/latest'): |
| sh.rm('log/latest') |
| sh.ln('-s', filename, 'log/latest') |
| self.terminal = sys.stdout |
| self.log = open('log/' + filename, 'w') |
| |
| def write(self, message): |
| self.terminal.write(message) |
| self.log.write(message) |
| |
| def flush(self): |
| self.terminal.flush() |
| self.log.flush() |
| |
| |
| sys.stdout = Logger() |
| |
| |
| def branch_name(branch_prefix, target, topic): |
| if topic is None: |
| topic = '' |
| else: |
| topic = '-' + topic.replace('/', '_') |
| return 'chromeos-' + branch_prefix + '-' + target[1:] + topic |
| |
| |
| def do_on_cros_sdk_impl(command, ret_by_arg=None): |
| result = {'exit_code': None, 'output': None, 'error_line': None} |
| os.system("echo '" + command + "' > " + executor_io + '/commands &') |
| os.system('cat ' + executor_io + '/output > output.log') |
| try: |
| with open(executor_io + '/last_exit') as last_exit: |
| ec = last_exit.read() |
| result['exit_code'] = int(ec[:-1]) |
| except: # pylint: disable=bare-except |
| print('failed to read a valid exit code from last_exit') |
| return {} |
| try: |
| with open('output.log') as output: |
| result['output'] = output.read() |
| lines = result['output'].splitlines() |
| for n in range(len(lines)): # pylint: disable=C0200 |
| if 'Error 1' in lines[n]: |
| result['error_line'] = n + 1 |
| break |
| except: # pylint: disable=bare-except |
| print('failed to read output.log') |
| if ret_by_arg is not None: |
| for k, v in result.items(): |
| ret_by_arg[k] = v |
| return result |
| |
| |
| def do_on_cros_sdk(command, timeout_s=None): |
| if timeout_s is not None: |
| manager = Manager() |
| result = {} |
| shared_dict = manager.dict() |
| p = multiprocessing.Process( |
| target=do_on_cros_sdk_impl, args=( |
| command, shared_dict,)) |
| p.start() |
| p.join(timeout_s) |
| if p.is_alive(): |
| print('execution timed out, is executor.sh running in cros_sdk?') |
| p.terminate() |
| p.join() |
| else: |
| for k, v in shared_dict.items(): |
| result[k] = v |
| return result |
| return do_on_cros_sdk_impl(command) |
| |
| def verify_build(sha, board): |
| assert not is_dirty( |
| 'kernel-next'), "There's a local diff in kernel repo. Clean it to continue." |
| if sha is not None: |
| checkout('kernel-next', sha) |
| return do_on_cros_sdk( |
| 'emerge-' + |
| board + |
| ' --color n -B chromeos-kernel-next') |
| |
| class Rebaser: |
| """Keeps all automatic rebase data""" |
| |
| def __init__(self, branch_prefix='test'): |
| assert not is_dirty( |
| 'kernel-next'), "There's a local diff in kernel repo. Clean it to continue." |
| |
| self.db = sqlite3.connect(rebasedb) |
| self.cur = self.db.cursor() |
| self.branch_prefix = branch_prefix |
| |
| # Create topic dict (name->gid) |
| self.topics = {} |
| self.cur.execute('select topic, name from topics') |
| t = self.cur.fetchall() |
| for gid, name in t: |
| self.topics[name] = gid |
| print('Topic dict: ', self.topics) |
| |
| self.upstreamed = { |
| 'upstream': 0, |
| 'fromlist': 0, |
| 'fromgit': 0, |
| 'backport': 0} |
| self.total = { |
| 'upstream': 0, |
| 'fromlist': 0, |
| 'fromgit': 0, |
| 'backport': 0} |
| self.cur.execute('select subject, reason from commits') |
| t = self.cur.fetchall() |
| for subject, reason in t: |
| subject_l = subject.lower() |
| if 'fromlist:' in subject_l: |
| self.total['fromlist'] += 1 |
| if 'upstream' in reason: |
| self.upstreamed['fromlist'] += 1 |
| if 'fromgit:' in subject_l: |
| self.total['fromgit'] += 1 |
| if 'upstream' in reason: |
| self.upstreamed['fromgit'] += 1 |
| if 'upstream:' in subject_l: |
| self.total['upstream'] += 1 |
| if 'upstream' in reason: |
| self.upstreamed['upstream'] += 1 |
| if 'backport:' in subject_l: |
| self.total['backport'] += 1 |
| if 'upstream' in reason: |
| self.upstreamed['backport'] += 1 |
| |
| self.kernel = None |
| |
| # Pull chromeos-5.4 branch |
| print('Fetching cros...') |
| fetch('kernel-next', 'cros') |
| |
| print('Fetching upstream...') |
| fetch('kernel-next', 'upstream') |
| |
| # Checkout to target branch |
| print('Checkout to', rebase_target, '...') |
| checkout('kernel-next', rebase_target) |
| |
| def get_topic_dispositions(self, topic_list): |
| # reload config to import up-to-date disp_overlay |
| importlib.reload(rebase_config) |
| from rebase_config import disp_overlay |
| |
| gids = [] |
| for topic in topic_list: |
| gids.append(self.topics[topic]) |
| gids = str(gids).replace('[', '(').replace(']', ')') |
| |
| self.cur.execute( |
| 'select disposition,sha,subject,reason from commits where topic in %s' % |
| gids) |
| dispositions = self.cur.fetchall() |
| for i in range(len(dispositions)): # pylint: disable=C0200 |
| disp = dispositions[i][0] |
| sha = dispositions[i][1] |
| subject = dispositions[i][2] |
| reason = dispositions[i][3] |
| # For now, assume there are only pick / drop / replace dispositions |
| assert disp in [ |
| 'pick', 'drop', 'replace'], 'Unrecognized disposition.' |
| # Modify dispositions according to overlay |
| if sha in disp_overlay: |
| dispositions[i] = (disp_overlay[sha], sha, subject, reason) |
| |
| return dispositions |
| |
| # Rebase many topic branches joining them into one topic branch. |
| # end_name: name of the target branch |
| # topics: list of source topics |
| # is_triage: if set, skip over commits that require manual resolution |
| def rebase_multiple(self, end_name, topic_list, is_triage=False): |
| # reload config to import up-to-date disp_overlay |
| importlib.reload(rebase_config) |
| |
| print('Checkout to', rebase_target, '...') |
| checkout('kernel-next', rebase_target) |
| |
| if is_triage: |
| topic_branch = branch_name('triage', rebase_target, end_name) |
| print('Triage mode on. Using branch %s.' % topic_branch) |
| with sh.pushd('kernel-next'): |
| try: |
| sh.git('branch', '-D', topic_branch) |
| except sh.ErrorReturnCode_1 as e: |
| pass |
| else: |
| topic_branch = branch_name( |
| self.branch_prefix, rebase_target, end_name) |
| |
| try: |
| create_head('kernel-next', topic_branch) |
| except OSError as err: |
| print(err) |
| print('Branch already exists?') |
| return {} |
| |
| print('Rebasing topics %s, branch %s' % (topic_list, end_name)) |
| |
| print('Checkout to %s...' % topic_branch) |
| checkout('kernel-next', topic_branch) |
| |
| dispositions = self.get_topic_dispositions(topic_list) |
| |
| dropped = 0 |
| noconflicts = 0 |
| autoresolved = 0 |
| manual = 0 |
| |
| dispositions_with_deps = [] |
| for i in dispositions: |
| sha = i[1] |
| if sha in rebase_config.patch_deps: |
| for dep in rebase_config.patch_deps[sha]: |
| print('Adding dependency', dep, 'for patch', sha) |
| subject = '(fake subject) Dependency of ' + sha |
| dispositions_with_deps.append(['pick', dep, subject, '']) |
| dispositions_with_deps.append(i) |
| |
| dispositions = dispositions_with_deps |
| for i in dispositions: |
| disp = i[0] |
| sha = i[1] |
| subject = i[2] |
| reason = i[3] |
| |
| if cp_or_am_in_progress('kernel-next'): |
| print('cherry-pick or am is currently in progress in kernel-next') |
| print('resolve and press enter to continue') |
| input() |
| |
| if disp == 'drop': |
| print('Drop commit (%s) %s: %s' % (reason, sha, subject)) |
| # don't count commits dropped because of upstreaming, to be |
| # consistent with genspreadsheet.py |
| if reason != 'upstream': |
| dropped += 1 |
| continue |
| print('Pick commit %s: %s' % (sha, subject)) |
| if disp == 'replace': |
| # Replace dispositions are treated as 'pick' to avoid the |
| # hassle. |
| print('WARNING: commit disposition is replace') |
| |
| diff = replacement('kernel-next', sha) |
| if diff is not None: |
| print('Patch replaced by previous conflict resolution:', diff) |
| # Make the path absolute |
| diff = os.getcwd() + '/' + diff |
| |
| err = 0 |
| try: |
| if diff is None: |
| cherry_pick('kernel-next', sha) |
| else: |
| apply_patch('kernel-next', diff) |
| noconflicts += 1 |
| # No conflicts, check rerere and continue |
| continue |
| except Exception as error: # pylint: disable=broad-except |
| err = error |
| |
| print('Conflicts found.') |
| # There were conflicts, check if autoresolved |
| # Autostage in git is assumed |
| # Files from patches shouldn't be autoresolved, so no path for handling |
| # git apply conflicts is added here |
| if is_resolved('kernel-next'): |
| print('All resolved automatically.') |
| autoresolved += 1 |
| with sh.pushd('kernel-next'): |
| try: |
| sh.git( |
| '-c', |
| 'core.editor=true', |
| 'cherry-pick', |
| '--continue') |
| except sh.ErrorReturnCode_1 as e: |
| if 'The previous cherry-pick is now empty' in str( |
| e.stderr): |
| print( |
| 'Cherry-pick empty due to conflict resolution. Skip.') |
| sh.git( |
| '-c', |
| 'core.editor=true', |
| 'cherry-pick', |
| '--abort') |
| continue |
| raise e |
| save_head('kernel-next', sha) |
| else: |
| # Detect the cases, where deleted file is the only meaningful |
| # conflict |
| with sh.pushd('kernel-next'): |
| status = sh.git('status', '--porcelain') |
| if 'DU' in status: |
| status = status.split('\n') |
| status = [line.split(' ') for line in status] |
| # By now, we have such array: |
| # [ ['DU', 'fs/compat_ioctl.c'] |
| # ['M', 'fs/ioctl.c' |
| # ] |
| # The only case where we can help is when conflicts are within |
| # the set (DU, M, ??, A). Check that now |
| conf_types = {a[0] for a in status} |
| if conf_types - set(['', '??', 'DU', 'M', 'A']) == set(): |
| # Cool, we can solve that - note the files to remove, others |
| # should autoresolve |
| for entry in status: |
| if entry[0] == 'DU': |
| with sh.pushd('kernel-next'): |
| sh.git('rm', entry[1]) |
| with sh.pushd('kernel-next'): |
| if diff is None: |
| try: |
| sh.git( |
| '-c', 'core.editor=true', 'cherry-pick', '--continue') |
| except sh.ErrorReturnCode_1 as e: |
| if 'The previous cherry-pick is now empty' in str( |
| e.stderr): |
| print( |
| 'Cherry-pick empty due to conflict resolution. Skip.') |
| sh.git( |
| '-c', 'core.editor=true', 'cherry-pick', '--abort') |
| continue |
| raise e |
| else: |
| try: |
| sh.git( |
| '-c', 'core.editor=true', 'am', '--continue') |
| except Exception as e: # pylint: disable=broad-except |
| print('git am --continue failed:') |
| print(e) |
| print('Fatal? [y/n]') |
| ans = input() |
| if ans in ['y', 'Y']: |
| return {} |
| print('Applied commit by removing conflicting files.') |
| save_head('kernel-next', sha) |
| continue |
| if is_triage: |
| # Conflict requires manual resolution - drop and continue |
| print('Commit requires manual resolution. Dropping it for now.') |
| manual += 1 |
| with sh.pushd('kernel-next'): |
| if diff is None: |
| sh.git('cherry-pick', '--abort') |
| else: |
| sh.git('am', '--abort') |
| continue |
| print( |
| """ |
| Conflict requires manual resolution. |
| Resolve it in another window, add the changes by git add, then |
| type \'continue\' (c) here. |
| Or drop this patch by typing \'drop\' (d). It will be recorded in |
| rebase_config.py and dropped in subsequent rebases. |
| Or stop the rebase altogether (while keeping the changes that |
| were already made) by typing \'stop\' (s). |
| """) |
| cmd = '' |
| while cmd not in ['continue', 'drop', 'stop', 's', 'c', 'd']: |
| cmd = input() |
| if cmd in ['continue', 'c']: |
| # Commit the change and continue |
| while not is_resolved('kernel-next'): |
| print('Something still unresolved. Resolve and hit enter.') |
| input() |
| manual += 1 |
| with sh.pushd('kernel-next'): |
| if diff is None: |
| try: |
| sh.git( |
| '-c', 'core.editor=true', 'cherry-pick', '--continue') |
| except sh.ErrorReturnCode_1 as e: |
| if 'The previous cherry-pick is now empty' in str( |
| e.stderr): |
| print( |
| 'Cherry-pick empty due to conflict resolution. Skip.') |
| sh.git( |
| '-c', 'core.editor=true', 'cherry-pick', '--abort') |
| continue |
| raise e |
| else: |
| try: |
| sh.git( |
| '-c', 'core.editor=true', 'am', '--continue') |
| except Exception as e: # pylint: disable=broad-except |
| print('git am --continue failed:') |
| print(e) |
| print('Fatal? [y/n]') |
| ans = input() |
| if ans in ['y', 'Y']: |
| return {} |
| save_head('kernel-next', sha) |
| elif cmd in ['drop', 'd']: |
| dropped += 1 |
| # Drop the commit and record as dropped in overlay |
| with sh.pushd('kernel-next'): |
| if diff is None: |
| sh.git('cherry-pick', '--abort') |
| else: |
| sh.git('am', '--abort') |
| with open('rebase_config.py', 'a') as f: |
| f.write( |
| "disp_overlay['%s'] = '%s' # %s\n" % |
| (sha, 'drop', subject)) |
| else: |
| print( |
| 'Stopped. %s commits dropped, %s applied cleanly, %s resolved' |
| ' automatically, %s needing manual resolution' % |
| (dropped, noconflicts, autoresolved, manual)) |
| with sh.pushd('kernel-next'): |
| if diff is None: |
| sh.git('cherry-pick', '--abort') |
| else: |
| sh.git('am', '--abort') |
| return {} |
| |
| # Apply global reverts |
| for sha in rebase_config.global_reverts: |
| with sh.pushd('kernel-next'): |
| sh.git('-c', 'core.editor=true', 'revert', sha) |
| |
| for topic in topic_list: |
| if topic in rebase_config.topic_fixups: |
| # Apply fixups for this particular topic |
| for sha in rebase_config.topic_fixups[topic]: |
| try: |
| cherry_pick('kernel-next', sha) |
| # No conflicts, check rerere and continue |
| print('Applied ' + sha + ' fixup for ' + topic + '.') |
| continue |
| except sh.ErrorReturnCode_1: |
| print( |
| 'Failed to apply topic fixup ' + |
| sha + |
| ' due to a conflict.') |
| with sh.pushd('kernel-next'): |
| sh.git('cherry-pick', '--abort') |
| |
| print('Done. %s commits dropped, %s applied cleanly, %s resolved' |
| ' automatically, %s needing manual resolution' % |
| (dropped, noconflicts, autoresolved, manual)) |
| |
| return {'dropped': dropped, 'noconflicts': noconflicts, |
| 'autoresolved': autoresolved, 'manual': manual} |
| |
| # Shorthand for rebase_multiple |
| def rebase_one(self, t, is_triage=False): |
| return self.rebase_multiple(t, [t], is_triage) |
| |
| # Moves commit into topic dst |
| # commit - sha string |
| # dst - topic name string |
| def topic_move(self, commit, dst): |
| dst_gid = self.topics[dst] |
| query = "select subject, topic from commits where sha='%s'" % commit |
| self.cur.execute(query) |
| ret = self.cur.fetchall() |
| src_gid = ret[0][1] |
| src = '' |
| for topic_name in self.topics: |
| if self.topics[topic_name] == src_gid: |
| src = src_gid |
| assert src != '', 'No such topic?' |
| query = "update commits set topic=%d where sha='%s'" % ( |
| dst_gid, commit) |
| self.cur.execute(query) |
| query = "select subject, topic from commits where sha='%s'" % commit |
| self.cur.execute(query) |
| ret = self.cur.fetchall() |
| assert dst_gid == ret[0][1] |
| print('Commit', ret[0][0], 'moved from', src, 'to', dst) |
| |
| def topic_list(self, topic): |
| dst_gid = self.topics[topic] |
| query = "select sha, subject from commits where topic=%d and disposition='pick'" % dst_gid |
| self.cur.execute(query) |
| ret = self.cur.fetchall() |
| for i in ret: |
| print(i[0], i[1]) |
| |
| |
| def triage(): |
| # Check if executor is alive, we'll need it for verifying build |
| if do_on_cros_sdk('true', 1) == {}: |
| print('Is executor running?') |
| return None |
| r = Rebaser() |
| topic_stats = r.topics |
| upstream_stats = r.upstreamed |
| total_stats = r.total |
| topic_stderr = {} |
| for topic in topic_stats: |
| topic_branch = branch_name('triage', rebase_target, topic) |
| ret = r.rebase_one(topic, is_triage=True) |
| topic_stats[topic] = [ |
| ret['dropped'] + |
| ret['noconflicts'] + |
| ret['autoresolved'] + |
| ret['manual'], |
| ret['dropped'] + |
| ret['autoresolved'] + |
| ret['noconflicts'], |
| ret['manual'], |
| False] |
| print('Verifying build...') |
| ret = verify_build(topic_branch, 'caroline') |
| if ret['exit_code'] == 0: |
| print('Built %s succesfully.' % topic) |
| topic_stats[topic][3] = True |
| else: |
| print('Error building %s:' % topic) |
| l = ret['error_line'] |
| reg = re.compile('\x1b\\[[0-9;]*m') |
| topic_stderr[topic] = reg.sub( |
| '', '\n'.join( |
| ret['output'].split('\n')[ |
| l - 7:l])) |
| print(topic_stderr[topic]) |
| f = open( |
| 'log/triage/' + |
| topic_branch.replace( |
| '.', |
| '_').replace( |
| '/', |
| '-') + |
| '.txt', |
| 'w') |
| f.write(ret['output']) |
| |
| # Pickle the topic stats. Those can be loaded later by |
| # Mailing::load_and_notify() |
| with open('topic_stats.bin', 'wb') as f: |
| pickle.dump(topic_stats, f) |
| with open('topic_stderr.bin', 'wb') as f: |
| pickle.dump(topic_stderr, f) |
| with open('upstream_stats.bin', 'wb') as f: |
| pickle.dump(upstream_stats, f) |
| with open('total_stats.bin', 'wb') as f: |
| pickle.dump(total_stats, f) |
| return (topic_stats, topic_stderr) |
| |
| |
| def merge_topic_branches(): |
| r = Rebaser() |
| topic_dict = r.topics |
| topic_list = rebase_config.merge_order_override |
| for from_config in rebase_config.merge_order_override: |
| if from_config not in topic_dict: |
| print( |
| "merge_order_override contains topics that aren't in line with topiclist") |
| sys.exit() |
| for topic in topic_dict: |
| if topic not in topic_list: |
| topic_list.append(topic) |
| |
| topic_branches = [ |
| branch_name( |
| 'kernelupstream', |
| rebase_target, |
| topic) for topic in topic_list] |
| merged_branch = branch_name('kernelupstream', rebase_target, None) |
| |
| print('checking out to ', rebase_target) |
| checkout('kernel-next', rebase_target) |
| |
| try: |
| print('creating head', merged_branch) |
| create_head('kernel-next', merged_branch) |
| except OSError as err: |
| print(err) |
| print('Branch already exists?') |
| return |
| |
| print('checking out to ', merged_branch) |
| checkout('kernel-next', merged_branch) |
| |
| for topic_branch in topic_branches: |
| print('Merging', topic_branch) |
| try: |
| with sh.pushd('kernel-next'): |
| sh.git('merge', '--no-edit', topic_branch) |
| continue |
| except sh.ErrorReturnCode_1 as error: |
| if 'not something we can merge' in str(error): |
| print( |
| 'topic has no corresponding branch (' + |
| topic_branch + |
| '), skipping') |
| continue |
| print('Conflict found') |
| if r.kernel.index.diff(None) == []: |
| print('Resolved automatically') |
| with sh.pushd('kernel-next'): |
| sh.git('-c', 'core.editor=/bin/true', 'merge', '--continue') |
| else: |
| print('Verify automatic resolution or resolve manually') |
| print('Enter [s]top to exit or c[ontinue] to proceed') |
| cmd = '' |
| while cmd not in ['continue', 'stop', 's', 'c']: |
| cmd = input() |
| if cmd in ['stop', 's']: |
| print('Exiting¡¬') |
| return |
| for fu in rebase_config.merge_fixups: |
| print('Applying fixup', fu) |
| try: |
| cherry_pick('kernel-next', fu) |
| except sh.ErrorReturnCode_1: |
| print('Conflict found') |
| print('Resolve (including git cherry-pick --continue) and update the fixup entry in config.py if necessary') # pylint: disable=C0301 |
| except Exception as err: # pylint: disable=broad-except |
| print('Uknown error occured:') |
| print(err) |
| print('Enter [s]top to exit or c[ontinue] to proceed') |
| |
| # The script only performs basic setup by itself. Specific actions |
| # are done via the Python shell created by this call to code.interact. |
| code.interact(local=locals()) |