diff --git a/gerrit_util.py b/gerrit_util.py index 859a1982c..808e67b29 100644 --- a/gerrit_util.py +++ b/gerrit_util.py @@ -1326,6 +1326,14 @@ def RestoreChange(host, change, msg=''): return ReadHttpJsonResponse(conn) +def RebaseChange(host, change, base=None): + """Rebases a change.""" + path = f'changes/{change}/rebase' + body = {'base': base} if base else {} + conn = CreateHttpConn(host, path, reqtype='POST', body=body) + return ReadHttpJsonResponse(conn) + + def SubmitChange(host, change): """Submits a Gerrit change via Gerrit.""" path = 'changes/%s/submit' % change @@ -1387,12 +1395,14 @@ def DeletePendingChangeEdit(host, change): ReadHttpResponse(conn, accept_statuses=[204, 404]) -def CherryPick(host, change, destination, revision='current'): +def CherryPick(host, change, destination, revision='current', message=None): """Create a cherry-pick commit from the given change, onto the given destination. """ path = 'changes/%s/revisions/%s/cherrypick' % (change, revision) body = {'destination': destination} + if message: + body['message'] = message conn = CreateHttpConn(host, path, reqtype='POST', body=body) return ReadHttpJsonResponse(conn) diff --git a/git_cl.py b/git_cl.py index a8b5bb00b..ecc23d4bf 100755 --- a/git_cl.py +++ b/git_cl.py @@ -4504,6 +4504,107 @@ def CMDissue(parser, args): return 0 +def _create_commit_message(orig_message, bug=None): + """Returns a commit message for the cherry picked CL.""" + orig_message_lines = orig_message.splitlines() + subj_line = orig_message_lines[0] + new_message = (f'Cherry pick "{subj_line}"\n\n' + "Original change's description:\n") + for line in orig_message_lines: + new_message += f'> {line}\n' + if bug: + new_message += f'\nBug: {bug}\n' + return new_message + + +# TODO(b/341792235): Add metrics. +@subcommand.usage('[revisions ...]') +def CMDcherry_pick(parser, args): + """Upload a chain of cherry picks to Gerrit. + + This must be run inside the git repo you're trying to make changes to. + """ + if gclient_utils.IsEnvCog(): + print('cherry-pick command is not supported in non-git environment', + file=sys.stderr) + return 1 + + parser.add_option('--branch', help='Gerrit branch, e.g. refs/heads/main') + parser.add_option('--bug', + help='Bug to add to the description of each change.') + parser.add_option('--parent-change-num', + type='int', + help='The parent change of the first cherry-pick CL, ' + 'i.e. the start of the CL chain.') + options, args = parser.parse_args(args) + + if not options.branch: + parser.error('Branch is required.') + if not args: + parser.error('No revisions to cherry pick.') + + # Gerrit needs a change ID for each commit we cherry pick. + change_ids_to_message = {} + change_ids_to_commit = {} + for commit in args: + message = git_common.run('show', '-s', '--format=%B', commit).strip() + if change_id := git_footers.get_footer_change_id(message): + change_ids_to_message[change_id[0]] = message + change_ids_to_commit[change_id[0]] = commit + continue + raise RuntimeError(f'Change ID not found for {commit}') + + print(f'Creating chain of {len(change_ids_to_message)} cherry pick(s)...') + + # Gerrit only supports cherry picking one commit per change, so we have + # to cherry pick each commit individually and create a chain of CLs. + host = Changelist().GetGerritHost() + parent_change_num = options.parent_change_num + for change_id, orig_message in change_ids_to_message.items(): + change_ids_to_commit.pop(change_id) + message = _create_commit_message(orig_message, options.bug) + + # Create a cherry pick first, then rebase. If we create a chained CL + # then cherry pick, the change will lose its relation to the parent. + new_change_info = gerrit_util.CherryPick(host, + change_id, + options.branch, + message=message) + new_change_id = new_change_info['change_id'] + new_change_num = new_change_info['_number'] + new_change_url = gerrit_util.GetChangePageUrl(host, new_change_num) + + orig_subj_line = orig_message.splitlines()[0] + print(f'Created cherry pick of "{orig_subj_line}": {new_change_url}') + + if parent_change_num: + try: + # TODO(b/341792235): gerrit_util will always retry failed Gerrit + # requests 5 times. This doesn't make sense if a rebase fails + # due to a merge conflict since the result won't change. Make + # RebaseChange retry at most once. + gerrit_util.RebaseChange(host, new_change_id, parent_change_num) + except gerrit_util.GerritError as e: + parent_change_url = gerrit_util.GetChangePageUrl( + host, parent_change_num) + print(f'Failed to rebase {new_change_url} on ' + f'{parent_change_url}: {e}. Please resolve any merge ' + 'conflicts.') + print('Once resolved, you can continue the CL chain with ' + f'`--parent-change-num={new_change_num}` to specify ' + 'which change the chain should start with.\n') + + if change_ids_to_message: + print('Remaining commit(s) to cherry pick:') + for commit in change_ids_to_commit.values(): + print(f' {commit}') + + return 1 + parent_change_num = new_change_num + + return 0 + + @metrics.collector.collect_metrics('git cl comments') def CMDcomments(parser, args): """Shows or posts review comments for any changelist.""" diff --git a/tests/git_cl_test.py b/tests/git_cl_test.py index 44fcc54f4..a67c1da51 100755 --- a/tests/git_cl_test.py +++ b/tests/git_cl_test.py @@ -5415,6 +5415,57 @@ class CMDLintTestCase(CMDTestCaseBase): git_cl.sys.stderr.getvalue()) +class CMDCherryPickTestCase(CMDTestCaseBase): + + def setUp(self): + super(CMDTestCaseBase, self).setUp() + + def testCreateCommitMessage(self): + orig_message = """Foo the bar + +This change foo's the bar. + +Bug: 123456 +Change-Id: I25699146b24c7ad8776f17775f489b9d41499595 +""" + expected_message = """Cherry pick "Foo the bar" + +Original change's description: +> Foo the bar +> +> This change foo's the bar. +> +> Bug: 123456 +> Change-Id: I25699146b24c7ad8776f17775f489b9d41499595 +""" + self.assertEqual(git_cl._create_commit_message(orig_message), + expected_message) + + def testCreateCommitMessageWithBug(self): + bug = "987654" + orig_message = """Foo the bar + +This change foo's the bar. + +Bug: 123456 +Change-Id: I25699146b24c7ad8776f17775f489b9d41499595 +""" + expected_message = f"""Cherry pick "Foo the bar" + +Original change's description: +> Foo the bar +> +> This change foo's the bar. +> +> Bug: 123456 +> Change-Id: I25699146b24c7ad8776f17775f489b9d41499595 + +Bug: {bug} +""" + self.assertEqual(git_cl._create_commit_message(orig_message, bug), + expected_message) + + if __name__ == '__main__': logging.basicConfig( level=logging.DEBUG if '-v' in sys.argv else logging.ERROR)