PKJ*w%ZZcherry_picker/__init__.py"""Backport CPython changes from master to maintenance branches.""" __version__ = '0.1.0' PK Jh7$]]cherry_picker/__main__.pyfrom .cherry_picker import cherry_pick_cli if __name__ == '__main__': cherry_pick_cli() PKz}J...cherry_picker/cherry_picker.py#!/usr/bin/env python3 # -*- coding: utf-8 -*- import click import os import subprocess import webbrowser import sys from . import __version__ class CherryPicker: def __init__(self, pr_remote, commit_sha1, branches, *, dry_run=False, push=True): self.pr_remote = pr_remote self.commit_sha1 = commit_sha1 self.branches = branches self.dry_run = dry_run self.push = push @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('.')))) @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') # implicit ssh URIs use : to separate host from user, others just use / username = result.replace(':', '/').split('/')[-2] return username def get_cherry_pick_branch(self, maint_branch): return f"backport-{self.commit_sha1[:7]}-{maint_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" def fetch_upstream(self): """ git fetch """ self.run_cmd(f"git fetch {self.upstream}") 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) else: output = subprocess.check_output(cmd, shell=True, stderr=subprocess.STDOUT) click.echo(output.decode('utf-8')) def checkout_branch(self, branch_name): """ git checkout -b """ 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.output) def get_commit_message(self, commit_sha): """ Return the commit message for the current commit hash, replace # with GH- """ 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" 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 """ 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. To continue and resolve the conflict: $ cherry_picker --status # to find out which files need attention # Fix the conflict $ cherry_picker --status # should now say 'all conflict fixed' $ cherry_picker --continue To abort the cherry-pick and cleanup: $ 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-') if self.dry_run: click.echo(f" dry-run: git commit --amend -m '{updated_commit_message}'") else: try: subprocess.check_output(["git", "commit", "--amend", "-m", updated_commit_message], stderr=subprocess.STDOUT) except subprocess.CalledProcessError as cpe: click.echo("Failed to amend the commit message \u2639") click.echo(cpe.output) def push_to_remote(self, base_branch, head_branch): """ git push """ cmd = f"git push {self.pr_remote} {head_branch}" try: self.run_cmd(cmd) except subprocess.CalledProcessError: click.echo(f"Failed to push to {self.pr_remote} \u2639") else: 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 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 backport(self): if not self.branches: raise click.UsageError("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 as cpe: click.echo(cpe.output) click.echo(self.get_exit_message(maint_branch)) sys.exit(-1) else: if self.push: self.push_to_remote(maint_branch, cherry_pick_branch) self.cleanup_branch(cherry_pick_branch) else: click.echo(\ f""" Finished cherry-pick {self.commit_sha1} into {cherry_pick_branch} \U0001F600 --no-push option used. ... Stopping here. To continue and push the changes: $ cherry_picker --continue To abort the cherry-pick and cleanup: $ cherry_picker --abort """) 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 as cpe: click.echo(cpe.output) # only delete backport branch created by cherry_picker.py if get_current_branch().startswith('backport-'): self.cleanup_branch(get_current_branch()) def continue_cherry_pick(self): """ git push origin open the PR clean up branch """ cherry_pick_branch = get_current_branch() if cherry_pick_branch.startswith('backport-'): # 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})' if self.dry_run: click.echo(f" dry-run: git commit -am '{updated_commit_message}' --allow-empty") else: 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) click.echo("\nBackport PR:\n") click.echo(updated_commit_message) else: click.echo(f"Current branch ({cherry_pick_branch}) is not a backport branch. Will not continue. \U0001F61B") CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) @click.command(context_settings=CONTEXT_SETTINGS) @click.version_option(version=__version__) @click.option('--dry-run', is_flag=True, help="Prints out the commands, but not executed.") @click.option('--pr-remote', '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.option('--push/--no-push', 'push', is_flag=True, default=True, help="Changes won't be pushed to remote") @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, push, commit_sha1, branches): click.echo("\U0001F40D \U0001F352 \u26CF") if not is_cpython_repo(): click.echo("You're not inside a CPython repo right now! 🙅") sys.exit(-1) if dry_run: click.echo("Dry run requested, listing expected command sequence") cherry_picker = CherryPicker(pr_remote, commit_sha1, branches, dry_run=dry_run, push=push) if abort is not None: if abort: cherry_picker.abort_cherry_pick() else: cherry_picker.continue_cherry_pick() elif status: click.echo(cherry_picker.status()) else: cherry_picker.backport() def get_base_branch(cherry_pick_branch): """ return '2.7' from 'backport-sha-2.7' """ prefix, sep, base_branch = cherry_pick_branch.rpartition('-') return base_branch def get_current_branch(): """ Return the current branch """ cmd = "git rev-parse --abbrev-ref HEAD" 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 is_cpython_repo(): cmd = "git log -r 7f777ed95a19224294949e1b4ce56bbffcb1fe9f" try: subprocess.check_output(cmd.split(), stderr=subprocess.STDOUT) except subprocess.SubprocessError: return False return True if __name__ == '__main__': cherry_pick_cli() PKt}Jycherry_picker/test.pyfrom unittest import mock import pytest from .cherry_picker import get_base_branch, get_current_branch, \ get_full_sha_from_short, is_cpython_repo, CherryPicker def test_get_base_branch(): cherry_pick_branch = 'backport-afc23f4-2.7' result = get_base_branch(cherry_pick_branch) assert result == '2.7' def test_get_base_branch_without_dash(): cherry_pick_branch ='master' result = get_base_branch(cherry_pick_branch) assert result == 'master' @mock.patch('subprocess.check_output') def test_get_current_branch(subprocess_check_output): subprocess_check_output.return_value = b'master' assert get_current_branch() == 'master' @mock.patch('subprocess.check_output') def test_get_full_sha_from_short(subprocess_check_output): mock_output = b"""commit 22a594a0047d7706537ff2ac676cdc0f1dcb329c tree 14ab2ea85e7a28adb9d40f185006308d87a67f47 parent 5908300e4b0891fc5ab8bd24fba8fac72012eaa7 author Armin Rigo 1492106895 +0200 committer Mariatta 1492106895 -0700 bpo-29694: race condition in pathlib mkdir with flags parents=True (GH-1089) diff --git a/Lib/pathlib.py b/Lib/pathlib.py index fc7ce5e..1914229 100644 --- a/Lib/pathlib.py +++ b/Lib/pathlib.py """ subprocess_check_output.return_value = mock_output assert get_full_sha_from_short('22a594a') == '22a594a0047d7706537ff2ac676cdc0f1dcb329c' @mock.patch('os.path.exists') def test_sorted_branch(os_path_exists): os_path_exists.return_value = True branches = ["3.1", "2.7", "3.10", "3.6"] cp = CherryPicker('origin', '22a594a0047d7706537ff2ac676cdc0f1dcb329c', branches) assert cp.sorted_branches == ["3.10", "3.6", "3.1", "2.7"] @mock.patch('os.path.exists') def test_get_cherry_pick_branch(os_path_exists): os_path_exists.return_value = True branches = ["3.6"] cp = CherryPicker('origin', '22a594a0047d7706537ff2ac676cdc0f1dcb329c', branches) assert cp.get_cherry_pick_branch("3.6") == "backport-22a594a-3.6" @mock.patch('os.path.exists') @mock.patch('subprocess.check_output') def test_get_pr_url(subprocess_check_output, os_path_exists): os_path_exists.return_value = True subprocess_check_output.return_value = b'https://github.com/mock_user/cpython.git' branches = ["3.6"] cp = CherryPicker('origin', '22a594a0047d7706537ff2ac676cdc0f1dcb329c', branches) assert cp.get_pr_url("3.6", cp.get_cherry_pick_branch("3.6")) \ == "https://github.com/python/cpython/compare/3.6...mock_user:backport-22a594a-3.6?expand=1" @pytest.mark.parametrize('url', [ b'git@github.com:mock_user/cpython.git', b'git@github.com:mock_user/cpython', b'ssh://git@github.com/mock_user/cpython.git', b'ssh://git@github.com/mock_user/cpython', b'https://github.com/mock_user/cpython.git', b'https://github.com/mock_user/cpython', ]) def test_username(url): with mock.patch('subprocess.check_output', return_value=url): branches = ["3.6"] cp = CherryPicker('origin', '22a594a0047d7706537ff2ac676cdc0f1dcb329c', branches) assert cp.username == 'mock_user' @mock.patch('os.path.exists') @mock.patch('subprocess.check_output') def test_get_updated_commit_message(subprocess_check_output, os_path_exists): os_path_exists.return_value = True subprocess_check_output.return_value = b'bpo-123: Fix Spam Module (#113)' branches = ["3.6"] cp = CherryPicker('origin', '22a594a0047d7706537ff2ac676cdc0f1dcb329c', branches) assert cp.get_commit_message('22a594a0047d7706537ff2ac676cdc0f1dcb329c') \ == 'bpo-123: Fix Spam Module (GH-113)' @mock.patch('subprocess.check_output') def test_is_cpython_repo(subprocess_check_output): subprocess_check_output.return_value = """commit 7f777ed95a19224294949e1b4ce56bbffcb1fe9f Author: Guido van Rossum Date: Thu Aug 9 14:25:15 1990 +0000 Initial revision """ assert is_cpython_repo() == True def test_is_not_cpython_repo(): assert is_cpython_repo() == False PKKJC'cherry_picker/.cache/v/cache/lastfailed{}PK!H2g*1O.cherry_picker-0.1.0.dist-info/entry_points.txtN+I/N.,()JH-*/LN-RU@x9\\PK!HuNRR#cherry_picker-0.1.0.dist-info/WHEEL1 0 RZq+D-Dv;_[*7Fp 6ՆKŵ|4|cBQaL2PK!HDV 9&cherry_picker-0.1.0.dist-info/METADATAYnF^>,@lǒ[!N jlbv9XvfhY@oPt{>~gf(lFbKÙs|0<ᆇJh/8x.Z~,J`a'ڍv*ϹZط<*2ec ͦJ,Ōħ0E,D\(N", ƔzR3&Q,~i)cDjE(!n+4wg>;U8F5W)7|z( S6diO2^/@q-2Y֖\Go"fJС#5bF8Z2QrxŌas|}@MDŽ$.6kЎSvTJ^(XK95 '6[v8&ᶭ9j#Hp(6R-z0ֵ&}*T"F߾@Z*gm'~Ʋ0iQ XVzBO3,OG=ٳȞOڙ/sD2můS|E(N [YJ% Qa mOuGD4@{P@x"U]V`BF^ foY3-H@^Vi2Z;c[p) S`XU4no#OSZe 'iI6-?YeZeh-Ad-:Viij>7,TQP7Y(;0 `빬04\kڭL@&64*pR$U&9E]tdBq9'oYRju:]O_A CZ$jsURF')#";WTxȷƧ"!c5J^I/O`.IǼʌ1{s8jB~vmc@+sЀj7ʌW Pu=ڮF 7]b|A @G6y~yB$jT!ัLۯ_ǿK4U] b&ax1Ih2jK6&Vyx. ]h'As3U3K4sg25rQiW:(#-e|FA {tWz"wןX㎕DN[?Jmu!ÚV^˄{pM͐WYA\+4L܈2"j-KKK=N\!r1bkE-k {BʑGIynHM~+I;+Q֊3vvYSR8{w.8r!-C  8i2Da=7S=qTs*7ygнyn^Ȅ[@ma"&_LCЙgH&;}_~ W!Ǔ!DމL,?Ny,Fua.,Kԩ n`dv-|pwr\u` {JI`]S7u8RV@ʾyߋrAmlA.5؜ È&ohѪN}mjSW>#]݃c:{k\-6.ʋbw/ܖ2OD݃x{p7}Ml euh:hZژ,V0q մ=h#+m[/u>sۧg|t|bo9ق[|60O0'*>܎`hdP& ]Xڷ;gYc^G hayƋ&N_Cs0ggÆyAkphpM{48i~;c]aD!];P0s%ټi%̓{+Z#`OlXSZ]zSY66:ebf彳X ק_S:s{KԮk5=~C]tomY}s4KAhé*Ҷ=Wv囹O| }K!v,d翙yV{v3nckg@=@c妒β{6ԵFn'ׂBfl5uLqZ3 oM.lAR# zE"J^8FISeϵ;nA@tX`[KgZnTcslTK:#8ӌ*<6=M65)MCuV64qLHN(j,y@PgM | 8 [/\\TUpC~aD5 ξ(k"nI;i><YT%RuJ[&&Tgld8Z֌NBcZԃĸܻa mxMKvIkOs}܃}/'/_8|PK!H\X$cherry_picker-0.1.0.dist-info/RECORDɒ@{? br(6B (,)B| #ZЧ̌?dv]7 JMՈ1یLI]L`a/Ɖ[A{CeTxOL{&Eӥ-%YkOX|TQvʆ^CKQd}2(2J lj])d8NVclt#l??,:LC x;Trmf]3"^56KS:W'y*xzܥG~ "ur)&CgdYm,b'XPO&, ʎڝDᦵXnZh!#69 J;"# j |wM yPKJ*w%ZZcherry_picker/__init__.pyPK Jh7$]]cherry_picker/__main__.pyPKz}J...%cherry_picker/cherry_picker.pyPKt}Jys/cherry_picker/test.pyPKKJC'?cherry_picker/.cache/v/cache/lastfailedPK!H2g*1O.?cherry_picker-0.1.0.dist-info/entry_points.txtPK!HuNRR#r@cherry_picker-0.1.0.dist-info/WHEELPK!HDV 9&Acherry_picker-0.1.0.dist-info/METADATAPK!H\X$%Lcherry_picker-0.1.0.dist-info/RECORDPK ,N