diff --git a/cherry_picker/cherry_picker.py b/cherry_picker/cherry_picker.py old mode 100644 new mode 100755 index 580c939..ffbacf2 --- a/cherry_picker/cherry_picker.py +++ b/cherry_picker/cherry_picker.py @@ -1,3 +1,6 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + import click import os import subprocess @@ -5,55 +8,137 @@ @click.command() -@click.option('--dry-run', is_flag=True) +@click.option('--dry-run', is_flag=True, + help="Prints out the commands, but not executed.") @click.option('--push', 'pr_remote', metavar='REMOTE', help='git remote to use for PR branches', default='origin') -@click.argument('commit_sha1', 'The commit sha1 to be cherry-picked') +@click.option('--abort', 'abort', flag_value=True, default=None, + help="Abort current cherry-pick and clean up branch") +@click.option('--continue', 'abort', flag_value=False, default=None, + help="Continue cherry-pick, push, and clean up branch") +@click.argument('commit_sha1', 'The commit sha1 to be cherry-picked', nargs=1, + default = "") @click.argument('branches', 'The branches to backport to', nargs=-1) -def cherry_pick(dry_run, pr_remote, commit_sha1, branches): +def cherry_pick(dry_run, pr_remote, abort, commit_sha1, branches): + click.echo("\U0001F40D \U0001F352 \u26CF ") if not os.path.exists('./pyconfig.h.in'): os.chdir('./cpython/') + upstream = get_git_fetch_remote() username = get_forked_repo_name(pr_remote) if dry_run: click.echo("Dry run requested, listing expected command sequence") + if abort is not None: + if abort: + abort_cherry_pick(dry_run=dry_run) + else: + continue_cherry_pick(username, pr_remote, dry_run=dry_run) - click.echo("fetching upstream ...") - run_cmd(f"git fetch {upstream}", dry_run=dry_run) + else: + backport_branches(commit_sha1, branches, username, upstream, pr_remote, + dry_run=dry_run) +def backport_branches(commit_sha1, branches, username, upstream, pr_remote, + *, dry_run=False): + if commit_sha1 == "": + raise ValueError("Missing the commit_sha1 argument.") if not branches: - raise ValueError("at least one branch is required") + raise ValueError("At least one branch is required.") + else: + run_cmd(f"git fetch {upstream}", dry_run=dry_run) - for branch in get_sorted_branch(branches): - click.echo(f"Now backporting '{commit_sha1}' into '{branch}'") + for branch in get_sorted_branch(branches): + click.echo(f"Now backporting '{commit_sha1}' into '{branch}'") - # git checkout -b 61e2bc7-3.5 upstream/3.5 - cherry_pick_branch = f"backport-{commit_sha1[:7]}-{branch}" - cmd = f"git checkout -b {cherry_pick_branch} {upstream}/{branch}" - run_cmd(cmd, dry_run=dry_run) + # git checkout -b backport-61e2bc7-3.5 upstream/3.5 + cherry_pick_branch = f"backport-{commit_sha1[:7]}-{branch}" + pr_url = get_pr_url(username, branch, cherry_pick_branch) + cmd = f"git checkout -b {cherry_pick_branch} {upstream}/{branch}" + run_cmd(cmd, dry_run=dry_run) - cmd = f"git cherry-pick -x {commit_sha1}" - if run_cmd(cmd, dry_run=dry_run): - cmd = f"git push {pr_remote} {cherry_pick_branch}" - if not run_cmd(cmd, dry_run=dry_run): - click.echo(f"Failed to push to {pr_remote} :(") + cmd = f"git cherry-pick -x {commit_sha1}" + if run_cmd(cmd, dry_run=dry_run): + push_to_remote(pr_url, pr_remote, cherry_pick_branch, dry_run=dry_run) + cleanup_branch(cherry_pick_branch, dry_run=dry_run) else: - open_pr(username, branch, cherry_pick_branch, dry_run=dry_run) - else: - click.echo(f"Failed to cherry-pick {commit_sha1} into {branch} :(") + click.echo(f"Failed to cherry-pick {commit_sha1} into {branch} \u2639") + click.echo(" ... Stopping here. ") + + click.echo("") + click.echo("To continue and resolve the conflict: ") + click.echo(" $ cd cpython") + click.echo(" $ git status # to find out which files need attention") + click.echo(" # Fix the conflict") + click.echo(" $ git status # should now say `all conflicts fixed`") + click.echo(" $ cd ..") + click.echo(" $ python -m cherry_picker --continue") + + click.echo("") + click.echo("To abort the cherry-pick and cleanup: ") + click.echo(" $ python -m cherry_picker --abort") + - cmd = "git checkout master" +def abort_cherry_pick(*, dry_run=False): + """ + run `git cherry-pick --abort` and then clean up the branch + """ + if run_cmd("git cherry-pick --abort", dry_run=dry_run): + cleanup_branch(get_current_branch(), dry_run=dry_run) + + +def continue_cherry_pick(username, pr_remote, *, dry_run=False): + """ + git push origin + open the PR + clean up branch + + """ + cherry_pick_branch = get_current_branch() + if cherry_pick_branch != 'master': + + # this has the same effect as `git cherry-pick --continue` + cmd = f"git commit -am 'Resolved.' --allow-empty" run_cmd(cmd, dry_run=dry_run) - cmd = f"git branch -D {cherry_pick_branch}" - if run_cmd(cmd, dry_run=dry_run): - if not dry_run: - click.echo(f"branch {cherry_pick_branch} has been deleted.") - else: - click.echo(f"branch {cherry_pick_branch} NOT deleted.") + base_branch = get_base_branch(cherry_pick_branch) + pr_url = get_pr_url(username, base_branch, cherry_pick_branch) + push_to_remote(pr_url, pr_remote, cherry_pick_branch, dry_run=dry_run) + + cleanup_branch(cherry_pick_branch, dry_run=dry_run) + else: + click.echo(u"Refuse to push to master \U0001F61B") + + +def get_base_branch(cherry_pick_branch): + """ + return '2.7' from 'backport-sha-2.7' + """ + return cherry_pick_branch[cherry_pick_branch.rfind('-')+1:] + + +def cleanup_branch(cherry_pick_branch, *, dry_run=False): + """ + git checkout master + git branch -D + """ + cmd = "git checkout master" + run_cmd(cmd, dry_run=dry_run) + + cmd = f"git branch -D {cherry_pick_branch}" + if run_cmd(cmd, dry_run=dry_run): + if not dry_run: + click.echo(f"branch {cherry_pick_branch} has been deleted.") + else: + click.echo(f"branch {cherry_pick_branch} NOT deleted.") +def push_to_remote(pr_url, pr_remote, cherry_pick_branch, *, dry_run=False): + cmd = f"git push {pr_remote} {cherry_pick_branch}" + if not run_cmd(cmd, dry_run=dry_run): + click.echo(f"Failed to push to {pr_remote} \u2639") + else: + open_pr(pr_url, dry_run=dry_run) def get_git_fetch_remote(): """Get the remote name to use for upstream branches @@ -70,7 +155,6 @@ def get_git_fetch_remote(): def get_forked_repo_name(pr_remote): """ Return 'myusername' out of https://github.com/myusername/cpython - :return: """ cmd = f"git config --get remote.{pr_remote}.url" raw_result = subprocess.check_output(cmd.split(), stderr=subprocess.STDOUT) @@ -94,17 +178,32 @@ def run_cmd(cmd, *, dry_run=False): return True -def open_pr(forked_repo, base_branch, cherry_pick_branch, *, dry_run=False): +def get_pr_url(forked_repo, base_branch, cherry_pick_branch): + """ + construct the url for the pull request + """ + return f"https://github.com/python/cpython/compare/{base_branch}...{forked_repo}:{cherry_pick_branch}?expand=1" + + +def open_pr(url, *, dry_run=False): """ - construct the url for pull request and open it in the web browser + open url in the web browser """ - url = f"https://github.com/python/cpython/compare/{base_branch}...{forked_repo}:{cherry_pick_branch}?expand=1" if dry_run: click.echo(f" dry-run: Create new PR: {url}") return webbrowser.open_new_tab(url) +def get_current_branch(): + """ + Return the current branch + """ + cmd = "git symbolic-ref HEAD | sed 's!refs\/heads\/!!'" + output = subprocess.check_output(cmd, shell=True) + return output.strip().decode() + + def get_sorted_branch(branches): return sorted( branches, diff --git a/cherry_picker/readme.rst b/cherry_picker/readme.rst index f002733..5c72c4b 100644 --- a/cherry_picker/readme.rst +++ b/cherry_picker/readme.rst @@ -1,6 +1,6 @@ Usage:: - python -m cherry_picker [--push REMOTE] [--dry-run] + python -m cherry_picker [--push REMOTE] [--dry-run] [--abort/--continue] @@ -43,6 +43,7 @@ repository are pushed to `origin`. If this is incorrect, then the correct remote will need be specified using the ``--push`` option (e.g. ``--push pr`` to use a remote named ``pr``). + Cherry-picking :snake: :cherries: :pick: ============== @@ -50,9 +51,34 @@ Cherry-picking :snake: :cherries: :pick: :: - (venv) $ python -m cherry_picker + (venv) $ python -m cherry_picker [--dry-run] [--abort/--continue] + +The commit sha1 is obtained from the merged pull request on ``master``. + + +Options +------- + +:: + + -- dry-run Dry Run Mode. Prints out the commands, but not executed. + -- push REMOTE Specify the branch to push into. Default is 'origin'. + + +Additional options:: + + -- abort Abort current cherry-pick and clean up branch + -- continue Continue cherry-pick, push, and clean up branch -The commit sha1 is obtained from the merged pull request on ``master``. + +Demo +---- + +https://asciinema.org/a/dfalzy45oq8b3c6dvakwfs6la + + +Example +------- For example, to cherry-pick ``6de2b7817f-some-commit-sha1-d064`` into ``3.5`` and ``3.6``: @@ -82,7 +108,22 @@ What this will do: (venv) $ git checkout master (venv) $ git branch -D backport-6de2b78-3.6 -In case of merge conflicts or errors, then... the script will fail :stuck_out_tongue: +In case of merge conflicts or errors, the following message will be displayed:: + + Failed to cherry-pick 554626ada769abf82a5dabe6966afa4265acb6a6 into 2.7 :frowning_face: + ... Stopping here. + + To continue and resolve the conflict: + $ cd cpython + $ git status # to find out which files need attention + # Fix the conflict + $ git status # should now say `all conflicts fixed` + $ cd .. + $ python -m cherry_picker --continue + + To abort the cherry-pick and cleanup: + $ python -m cherry_picker --abort + Passing the `--dry-run` option will cause the script to print out all the steps it would execute without actually executing any of them. For example:: @@ -106,6 +147,7 @@ steps it would execute without actually executing any of them. For example:: dry_run: git checkout master dry_run: git branch -D backport-1e32a1b-3.5 + Creating Pull Requests ======================