Repository navigation
Add --abort/--continue, --status, auto prefix commit msg with [X.Y] #68
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 7 commits
fd1fbac
ab24a6b
50d2e8b
d59ec53
1bf6d48
c190b3b
a373a95
6a0b071
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,116 +1,295 @@ | ||
| #!/usr/bin/env python3 | ||
| # -*- coding: utf-8 -*- | ||
|
|
||
| import click | ||
| import os | ||
| import subprocess | ||
| import webbrowser | ||
| import sys | ||
|
|
||
| class CherryPicker: | ||
|
|
||
| @click.command() | ||
| @click.option('--dry-run', is_flag=True) | ||
| @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.argument('branches', 'The branches to backport to', nargs=-1) | ||
| def cherry_pick(dry_run, pr_remote, commit_sha1, branches): | ||
| if not os.path.exists('./pyconfig.h.in'): | ||
| os.chdir('./cpython/') | ||
| upstream = get_git_fetch_remote() | ||
| username = get_forked_repo_name(pr_remote) | ||
| def __init__(self, pr_remote, commit_sha1, branches, | ||
| *, dry_run=False): | ||
| self.pr_remote = pr_remote | ||
| self.commit_sha1 = commit_sha1 | ||
| self.branches = branches | ||
| self.dry_run = dry_run | ||
|
|
||
| if dry_run: | ||
| click.echo("Dry run requested, listing expected command sequence") | ||
| @property | ||
| def upstream(self): | ||
| """Get the remote name to use for upstream branches | ||
| Uses "upstream" if it exists, "origin" otherwise | ||
| """ | ||
| cmd = "git remote get-url upstream" | ||
| try: | ||
| subprocess.check_output(cmd.split(), stderr=subprocess.DEVNULL) | ||
| except subprocess.CalledProcessError: | ||
| return "origin" | ||
| return "upstream" | ||
|
|
||
| @property | ||
| def sorted_branches(self): | ||
| return sorted( | ||
| self.branches, | ||
| reverse=True, | ||
| key=lambda v: tuple(map(int, v.split('.')))) | ||
|
|
||
| click.echo("fetching upstream ...") | ||
| run_cmd(f"git fetch {upstream}", dry_run=dry_run) | ||
| @property | ||
| def username(self): | ||
| cmd = f"git config --get remote.{self.pr_remote}.url" | ||
| raw_result = subprocess.check_output(cmd.split(), | ||
| stderr=subprocess.STDOUT) | ||
| result = raw_result.decode('utf-8') | ||
| username_end = result.index('/cpython.git') | ||
| if result.startswith("https"): | ||
| username = result[len("https://github.com/"):username_end] | ||
| else: | ||
| username = result[len("git@github.com:"):username_end] | ||
| return username | ||
|
|
||
| if not branches: | ||
| raise ValueError("at least one branch is required") | ||
| def get_cherry_pick_branch(self, maint_branch): | ||
| return f"backport-{self.commit_sha1[:7]}-{maint_branch}" | ||
|
|
||
| for branch in get_sorted_branch(branches): | ||
| click.echo(f"Now backporting '{commit_sha1}' into '{branch}'") | ||
| def get_pr_url(self, base_branch, head_branch): | ||
| return f"https://github.com/python/cpython/compare/{base_branch}...{self.username}:{head_branch}?expand=1" | ||
|
|
||
| # 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) | ||
| def fetch_upstream(self): | ||
| """ git fetch <upstream> """ | ||
| # self.run_cmd(f"git fetch {self.upstream}") | ||
| pass | ||
|
|
||
| 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} :(") | ||
| else: | ||
| open_pr(username, branch, cherry_pick_branch, dry_run=dry_run) | ||
| def run_cmd(self, cmd, shell=False): | ||
| if self.dry_run: | ||
| click.echo(f" dry-run: {cmd}") | ||
| return | ||
| if not shell: | ||
| output = subprocess.check_output(cmd.split(), stderr=subprocess.STDOUT) | ||
| click.echo(output.decode('utf-8')) | ||
| else: | ||
| click.echo(f"Failed to cherry-pick {commit_sha1} into {branch} :(") | ||
| subprocess.check_output(cmd, shell=True, stderr=subprocess.STDOUT) | ||
|
|
||
| def checkout_branch(self, branch_name): | ||
| """ git checkout -b <branch_name> """ | ||
| cmd = f"git checkout -b {self.get_cherry_pick_branch(branch_name)} {self.upstream}/{branch_name}" | ||
| try: | ||
| self.run_cmd(cmd) | ||
| except subprocess.CalledProcessError as err: | ||
| click.echo("error checking out branch") | ||
| click.echo(err) | ||
|
|
||
| def get_commit_message(self, commit_sha): | ||
| """ | ||
| Return the commit message for the current commit hash, | ||
| replace #<PRID> with GH-<PRID> | ||
| """ | ||
| cmd = f"git show -s --format=%B {commit_sha}" | ||
| output = subprocess.check_output(cmd.split(), stderr=subprocess.STDOUT) | ||
| updated_commit_message = output.strip().decode('utf-8').replace('#', 'GH-') | ||
| return updated_commit_message | ||
|
|
||
| def checkout_master(self): | ||
| """ git checkout master """ | ||
| cmd = "git checkout master" | ||
| run_cmd(cmd, dry_run=dry_run) | ||
| self.run_cmd(cmd) | ||
|
|
||
| def status(self): | ||
| """ | ||
| git status | ||
| :return: | ||
| """ | ||
| cmd = "git status" | ||
| self.run_cmd(cmd) | ||
|
|
||
| def cherry_pick(self): | ||
| """ git cherry-pick -x <commit_sha1> """ | ||
| cmd = f"git cherry-pick -x {self.commit_sha1}" | ||
| self.run_cmd(cmd) | ||
|
|
||
| def get_exit_message(self, branch): | ||
| return \ | ||
| f""" | ||
| Failed to cherry-pick {self.commit_sha1} into {branch} \u2639 | ||
| ... Stopping here. | ||
|
|
||
| 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.") | ||
| To continue and resolve the conflict: | ||
| $ python -m cherry_picker --status # to find out which files need attention | ||
| $ cd cpython | ||
| # Fix the conflict | ||
| $ cd .. | ||
| $ python -m cherry_picker --status # should now say 'all conflict fixed' | ||
| $ python -m cherry_picker --continue | ||
|
|
||
| To abort the cherry-pick and cleanup: | ||
| $ python -m cherry_picker --abort | ||
| """ | ||
|
|
||
| def amend_commit_message(self, cherry_pick_branch): | ||
| """ prefix the commit message with (X.Y) """ | ||
| base_branch = get_base_branch(cherry_pick_branch) | ||
|
|
||
| updated_commit_message = f"[{base_branch}] {self.get_commit_message(self.commit_sha1)}{os.linesep}(cherry picked from commit {self.commit_sha1})" | ||
| updated_commit_message = updated_commit_message.replace('#', 'GH-') | ||
| subprocess.check_output(["git", "commit", "--amend", "-m", | ||
| updated_commit_message], | ||
| stderr=subprocess.STDOUT) | ||
|
|
||
| def push_to_remote(self, base_branch, head_branch): | ||
| """ git push <origin> <branchname> """ | ||
|
|
||
| cmd = f"git push {self.pr_remote} {head_branch}" | ||
| try: | ||
| self.run_cmd(cmd) | ||
| except: | ||
| click.echo(f"Failed to push to {self.pr_remote} \u2639") | ||
| else: | ||
| click.echo(f"branch {cherry_pick_branch} NOT deleted.") | ||
| self.open_pr(self.get_pr_url(base_branch, head_branch)) | ||
|
|
||
| def open_pr(self, url): | ||
| """ | ||
| open url in the web browser | ||
| """ | ||
| if self.dry_run: | ||
| click.echo(f" dry-run: Create new PR: {url}") | ||
| else: | ||
| webbrowser.open_new_tab(url) | ||
|
|
||
| def get_git_fetch_remote(): | ||
| """Get the remote name to use for upstream branches | ||
| Uses "upstream" if it exists, "origin" otherwise | ||
| """ | ||
| cmd = "git remote get-url upstream" | ||
| try: | ||
| subprocess.check_output(cmd.split(), stderr=subprocess.DEVNULL) | ||
| except subprocess.CalledProcessError: | ||
| return "origin" | ||
| return "upstream" | ||
| def delete_branch(self, branch): | ||
| cmd = f"git branch -D {branch}" | ||
| self.run_cmd(cmd) | ||
|
|
||
| def cleanup_branch(self, branch): | ||
| self.checkout_master() | ||
| try: | ||
| self.delete_branch(branch) | ||
| except subprocess.CalledProcessError: | ||
| click.echo(f"branch {branch} NOT deleted.") | ||
| else: | ||
| click.echo(f"branch {branch} has been deleted.") | ||
|
|
||
| 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) | ||
| result = raw_result.decode('utf-8') | ||
| username_end = result.index('/cpython.git') | ||
| if result.startswith("https"): | ||
| username = result[len("https://github.com/"):username_end] | ||
| else: | ||
| username = result[len("git@github.com:"):username_end] | ||
| return username | ||
| def backport(self): | ||
| if not self.branches: | ||
| raise ValueError("At least one branch must be specified.") | ||
| self.fetch_upstream() | ||
|
|
||
| for maint_branch in self.sorted_branches: | ||
| click.echo(f"Now backporting '{self.commit_sha1}' into '{maint_branch}'") | ||
|
|
||
| cherry_pick_branch = self.get_cherry_pick_branch(maint_branch) | ||
| self.checkout_branch(maint_branch) | ||
| try: | ||
| self.cherry_pick() | ||
| self.amend_commit_message(cherry_pick_branch) | ||
| except subprocess.CalledProcessError: | ||
| click.echo(self.get_exit_message(maint_branch)) | ||
| sys.exit(-1) | ||
| else: | ||
| self.push_to_remote(maint_branch, cherry_pick_branch) | ||
| self.cleanup_branch(cherry_pick_branch) | ||
|
|
||
|
|
||
| def run_cmd(cmd, *, dry_run=False): | ||
| def abort_cherry_pick(self): | ||
| """ | ||
| run `git cherry-pick --abort` and then clean up the branch | ||
| """ | ||
| cmd = "git cherry-pick --abort" | ||
| try: | ||
| self.run_cmd(cmd) | ||
| except subprocess.CalledProcessError: | ||
| pass | ||
| else: | ||
| self.cleanup_branch(get_current_branch()) | ||
|
|
||
| def continue_cherry_pick(self): | ||
| """ | ||
| git push origin <current_branch> | ||
| open the PR | ||
| clean up branch | ||
| """ | ||
| cherry_pick_branch = get_current_branch() | ||
|
|
||
| if cherry_pick_branch != 'master': | ||
|
|
||
| # amend the commit message, prefix with [X.Y] | ||
| base = get_base_branch(cherry_pick_branch) | ||
| short_sha = cherry_pick_branch[cherry_pick_branch.index('-')+1:cherry_pick_branch.index(base)-1] | ||
| full_sha = get_full_sha_from_short(short_sha) | ||
| commit_message = self.get_commit_message(short_sha) | ||
| updated_commit_message = f'[{base}]: {commit_message}. \n(cherry picked from commit {full_sha})' | ||
| subprocess.check_output(["git", "commit", "-am", updated_commit_message, "--allow-empty"], stderr=subprocess.STDOUT) | ||
|
|
||
| self.push_to_remote(base, cherry_pick_branch) | ||
|
|
||
| self.cleanup_branch(cherry_pick_branch) | ||
| else: | ||
| click.echo(u"Refuse to push to master \U0001F61B") | ||
|
|
||
| @click.command() | ||
| @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.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.option('--status', 'status', flag_value = True, default=None, | ||
| help="Get the status of cherry-pick") | ||
| @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_cli(dry_run, pr_remote, abort, status, commit_sha1, branches): | ||
|
|
||
| click.echo("\U0001F40D \U0001F352 \u26CF") | ||
| current_dir = os.path.dirname(os.path.abspath(__file__)) | ||
| pyconfig_path = os.path.join(current_dir, '/pyconfig.h.in') | ||
|
|
||
| if not os.path.exists(pyconfig_path): | ||
| os.chdir(os.path.join(current_dir, 'cpython')) | ||
|
|
||
| if dry_run: | ||
| click.echo(f" dry-run: {cmd}") | ||
| return True | ||
| try: | ||
| subprocess.check_output(cmd.split()) | ||
| except subprocess.CalledProcessError: | ||
| return False | ||
| return True | ||
| click.echo("Dry run requested, listing expected command sequence") | ||
|
|
||
| cherry_picker = CherryPicker(pr_remote, commit_sha1, branches, dry_run=dry_run) | ||
|
|
||
| if abort is not None: | ||
| if abort: | ||
| cherry_picker.abort_cherry_pick() | ||
| else: | ||
| cherry_picker.continue_cherry_pick() | ||
|
|
||
| elif status: | ||
| cherry_picker.status() | ||
| else: | ||
| cherry_picker.backport() | ||
|
|
||
|
|
||
| def open_pr(forked_repo, base_branch, cherry_pick_branch, *, dry_run=False): | ||
| def get_base_branch(cherry_pick_branch): | ||
| """ | ||
| construct the url for pull request and open it in the web browser | ||
| return '2.7' from 'backport-sha-2.7' | ||
| """ | ||
| 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) | ||
| if '-' in cherry_pick_branch: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Specifically, I think it's just:
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done :) |
||
| return cherry_pick_branch[cherry_pick_branch.rfind('-')+1:] | ||
| else: | ||
| return cherry_pick_branch | ||
|
|
||
|
|
||
| def get_current_branch(): | ||
| """ | ||
| Return the current branch | ||
| """ | ||
| cmd = "git symbolic-ref HEAD | sed 's!refs\/heads\/!!'" | ||
| output = subprocess.check_output(cmd, shell=True, stderr=subprocess.STDOUT) | ||
| return output.strip().decode('utf-8') | ||
|
|
||
|
|
||
| def get_full_sha_from_short(short_sha): | ||
| cmd = f"git show --format=raw {short_sha}" | ||
| output = subprocess.check_output(cmd, shell=True, stderr=subprocess.STDOUT) | ||
| full_sha = output.strip().decode('utf-8').split('\n')[0].split()[1] | ||
| return full_sha | ||
|
|
||
| def get_sorted_branch(branches): | ||
| return sorted( | ||
| branches, | ||
| reverse=True, | ||
| key=lambda v: tuple(map(int, v.split('.')))) | ||
|
|
||
|
|
||
| if __name__ == '__main__': | ||
| cherry_pick() | ||
| cherry_pick_cli() | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this still meant to be commented out?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the catch! 😅 Re-enabled this.