10000 Add initial impl of storinig state in Git config · python/core-workflow@8b05f5b · GitHub
[go: up one dir, main page]

Skip to content

Commit 8b05f5b

Browse files
committed
Add initial impl of storinig state in Git config
1 parent 25b3791 commit 8b05f5b

File tree

1 file changed

+174
-21
lines changed

1 file changed

+174
-21
lines changed

cherry_picker/cherry_picker/cherry_picker.py

Lines changed: 174 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import click
55
import collections
66
import os
7-
import pathlib
87
import subprocess
98
import webbrowser
109
import re
@@ -16,6 +15,9 @@
1615

1716
from . import __version__
1817

18+
19+
chosen_config_path = None
20+
1921
CREATE_PR_URL_TEMPLATE = ("https://api.github.com/repos/"
2022
"{config[team]}/{config[repo]}/pulls")
2123
DEFAULT_CONFIG = collections.ChainMap({
@@ -41,6 +43,11 @@ class InvalidRepoException(Exception):
4143

4244
class CherryPicker:
4345

46+
ALLOWED_STATES = (
47+
'BACKPORT_PAUSED',
48+
'UNSET',
49+
)
50+
4451
def __init__(self, pr_remote, commit_sha1, branches,
4552
*, dry_run=False, push=True,
4653
prefix_commit=True,
@@ -50,6 +57,8 @@ def __init__(self, pr_remote, commit_sha1, branches,
5057
self.config = config
5158
self.check_repo() # may raise InvalidRepoException
5259

60+
self.initial_state = self.get_state_and_verify()
61+
5362
if dry_run:
5463
click.echo("Dry run requested, listing expected command sequence")
5564

@@ -97,8 +106,10 @@ def get_pr_url(self, base_branch, head_branch):
97106

98107
def fetch_upstream(self):
99108
""" git fetch <upstream> """
109+
set_state('FETCHING_UPSTREAM')
100110
cmd = ['git', 'fetch', self.upstream]
101111
self.run_cmd(cmd)
112+
set_state('FETCHED_UPSTREAM')
102113

103114
def run_cmd(self, cmd):
104115
assert not isinstance(cmd, str)
@@ -133,10 +144,13 @@ def get_commit_message(self, commit_sha):
133144

134145
def checkout_default_branch(self):
135146
""" git checkout default branch """
147+
set_state('CHECKING_OUT_DEFAULT_BRANCH')
136148

137149
cmd = 'git', 'checkout', self.config['default_branch']
138150
self.run_cmd(cmd)
139151

152+
set_state('CHECKED_OUT_DEFAULT_BRANCH')
153+
140154
def status(self):
141155
"""
142156
git status
@@ -196,19 +210,24 @@ def amend_commit_message(self, cherry_pick_branch):
196210

197211
def push_to_remote(self, base_branch, head_branch, commit_message=""):
198212
""" git push <origin> <branchname> """
213+
set_state('PUSHING_TO_REMOTE')
199214

200215
cmd = ['git', 'push', self.pr_remote, f'{head_branch}:{head_branch}']
201216
try:
202217
self.run_cmd(cmd)
218+
set_state('PUSHED_TO_REMOTE')
203219
except subprocess.CalledProcessError:
204220
click.echo(f"Failed to push to {self.pr_remote} \u2639")
221+
set_state('PUSHING_TO_REMOTE_FAILED')
205222
else:
206223
gh_auth = os.getenv("GH_AUTH")
207224
if gh_auth:
225+
set_state('PR_CREATING')
208226
self.create_gh_pr(base_branch, head_branch,
209227
commit_message=commit_message,
210228
gh_auth=gh_auth)
211229
else:
230+
set_state('PR_OPENING')
212231
self.open_pr(self.get_pr_url(base_branch, head_branch))
213232

214233
def create_gh_pr(self, base_branch, head_branch, *,
@@ -253,20 +272,26 @@ def delete_branch(self, branch):
253272
self.run_cmd(cmd)
254273

255274
def cleanup_branch(self, branch):
275+
set_state('REMOVING_BACKPORT_BRANCH')
256276
self.checkout_default_branch()
257277
try:
258278
self.delete_branch(branch)
259279
except subprocess.CalledProcessError:
260280
click.echo(f"branch {branch} NOT deleted.")
281+
set_state('REMOVING_BACKPORT_BRANCH_FAILED')
261282
else:
262283
click.echo(f"branch {branch} has been deleted.")
284+
set_state('REMOVED_BACKPORT_BRANCH')
263285

264286
def backport(self):
265287
if not self.branches:
266288
raise click.UsageError("At least one branch must be specified.")
289+
set_state('BACKPORT_STARTING')
267290
self.fetch_upstream()
268291

292+
set_state('BACKPORT_LOOPING')
269293
for maint_branch in self.sorted_branches:
294+
set_state('BACKPORT_LOOP_START')
270295
click.echo(f"Now backporting '{self.commit_sha1}' into '{maint_branch}'")
271296

272297
cherry_pick_branch = self.get_cherry_pick_branch(maint_branch)
@@ -280,6 +305,7 @@ def backport(self):
280305
click.echo(self.get_exit_message(maint_branch))
281306
except CherryPickException:
282307
click.echo(self.get_exit_message(maint_branch))
308+
set_paused_state()
283309
raise
284310
else:
285311
if self.push:
@@ -299,28 +325,44 @@ def backport(self):
299325
To abort the cherry-pick and cleanup:
300326
$ cherry_picker --abort
301327
""")
328+
set_paused_state()
329+
set_state('BACKPORT_LOOP_END')
330+
set_state('BACKPORT_COMPLETE')
302331

303332
def abort_cherry_pick(self):
304333
"""
305334
run `git cherry-pick --abort` and then clean up the branch
306335
"""
336+
if self.initial_state != 'BACKPORT_PAUSED':
337+
raise ValueError('One can only abort a paused process.')
338+
307339
cmd = ['git', 'cherry-pick', '--abort']
308340
try:
341+
set_state('ABORTING')
309342
self.run_cmd(cmd)
343+
set_state('ABORTED')
310344
except subprocess.CalledProcessError as cpe:
311345
click.echo(cpe.output)
346+
set_state('ABORTING_FAILED')
312347
# only delete backport branch created by cherry_picker.py
313348
if get_current_branch().startswith('backport-'):
314349
self.cleanup_branch(get_current_branch())
315350

351+
reset_stored_config_ref()
352+
reset_state()
353+
316354
def continue_cherry_pick(self):
317355
"""
318356
git push origin <current_branch>
319357
open the PR
320358
clean up branch
321359
"""
360+
if self.initial_state != 'BACKPORT_PAUSED':
361+
raise ValueError('One can only continue a paused process.')
362+
322363
cherry_pick_branch = get_current_branch()
323364
if cherry_pick_branch.startswith('backport-'):
365+
set_state('CONTINUATION_STARTED')
324366
# amend the commit message, prefix with [X.Y]
325367
base = get_base_branch(cherry_pick_branch)
326368
short_sha = cherry_pick_branch[cherry_pick_branch.index('-')+1:cherry_pick_branch.index(base)-1]
@@ -344,9 +386,14 @@ def continue_cherry_pick(self):
344386

345387
click.echo("\nBackport PR:\n")
346388
click.echo(updated_commit_message)
389+
set_state('BACKPORTING_CONTINUATION_SUCCEED')
347390

348391
else:
349392
click.echo(f"Current branch ({cherry_pick_branch}) is not a backport branch. Will not continue. \U0001F61B")
393+
set_state('CONTINUATION_FAILED')
394+
395+
reset_stored_config_ref()
396+
reset_state()
350397

351398
def check_repo(self):
352399
"""
@@ -360,6 +407,22 @@ def check_repo(self):
360407
except ValueError:
361408
raise InvalidRepoException()
362409

410+
def get_state_and_verify(self):
411+
state = get_state()
412+
if state not in self.ALLOWED_STATES:
413+
raise ValueError(
414+
f'Run state cherry-picker.state={state} in Git config '
415+
'is not known.\nPerhaps it has been set by a newer '
416+
'version of cherry-picker. Try upgrading.\n'
417+
f'Valid states are: {", ".join(self.ALLOWED_STATES)}. '
418+
'If this looks suspicious, raise an issue at '
419+
'https://github.com/python/core-workflow/issues/new.\n'
420+
'As the last resort you can reset the runtime state '
421+
'stored in Git config using the following command: '
422+
'`git config --local --remove-section cherry-picker`'
423+
)
424+
return state
425+
363426

364427
CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'])
365428

@@ -379,17 +442,20 @@ def check_repo(self):
379442
help="Changes won't be pushed to remote")
380443
@click.option('--config-path', 'config_path', metavar='CONFIG-PATH',
381444
help=("Path to config file, .cherry_picker.toml "
382-
"from project root by default"),
445+
"from project root by default. You can prepend "
446+
"a colon-separated Git 'commitish' reference."),
383447
default=None)
384448
@click.argument('commit_sha1', 'The commit sha1 to be cherry-picked', nargs=1,
385449
default = "")
386450
@click.argument('branches', 'The branches to backport to', nargs=-1)
387451
def cherry_pick_cli(dry_run, pr_remote, abort, status, push, config_path,
388452
commit_sha1, branches):
453+
ctx = click.get_current_context()
389454

390455
click.echo("\U0001F40D \U0001F352 \u26CF")
391456

392-
config = load_config(config_path)
457+
global chosen_config_path
458+
chosen_config_path, config = load_config(config_path)
393459

394460
try:
395461
cherry_picker = CherryPicker(pr_remote, commit_sha1, branches,
@@ -398,6 +464,8 @@ def cherry_pick_cli(dry_run, pr_remote, abort, status, push, config_path,
398464
except InvalidRepoException:
399465
click.echo(f"You're not inside a {config['repo']} repo right now! \U0001F645")
400466
sys.exit(-1)
467+
except ValueError as exc:
468+
ctx.fail(exc)
401469

402470
if abort is not None:
403471
if abort:
@@ -498,31 +566,116 @@ def normalize_commit_message(commit_message):
498566
return title, body.lstrip("\n")
499567

500568

501-
def find_project_root():
502-
cmd = ['git', 'rev-parse', '--show-toplevel']
503-
output = subprocess.check_output(cmd, stderr=subprocess.STDOUT)
504-
return pathlib.Path(output.decode('utf-8').strip())
569+
def is_git_repo():
570+
cmd = ['git', 'rev-parse', '--git-dir']
571+
try:
572+
subprocess.run(cmd, stdout=subprocess.DEVNULL, check=True)
573+
return True
574+
except subprocess.CalledProcessError:
575+
return False
576+
577+
578+
def find_config(revision):
579+
root = is_git_repo()
580+
if root:
581+
# git cat-file -e HEAD~79:.cherry_picker.toml
582+
cfg_path = f'{revision}:.cherry_picker.toml'
583+
cmd = 'git', 'cat-file', '-t', cfg_path
505584

585+
try:
586+
output = subprocess.check_output(cmd, stderr=subprocess.STDOUT)
587+
path_type = output.strip().decode('utf-8')
588+
return cfg_path if path_type == 'blob' else None
589+
except subprocess.CalledProcessError:
590+
return None
506591

507-
def find_config():
508-
root = find_project_root()
509-
if root is not None:
510-
child = root / '.cherry_picker.toml'
511-
if child.exists() and not child.is_dir():
512-
return child
513592
return None
514593

515594

516-
def load_config(path):
517-
if path is None:
518-
path = find_config()
595+
def load_config(path=None):
596+
# Initially I wanted to inherit Path to encapsulate Git access there
597+
# but there's no easy way to subclass pathlib.Path :(
598+
#import ipdb; ipdb.set_trace()
599+
head_sha = get_sha1_from('HEAD')
600+
revision = head_sha
601+
saved_config_path = load_val_from_git_cfg('config_path')
602+
if not path and saved_config_path is not None:
603+
path = saved_config_path
604+
519605
if path is None:
520-
return DEFAULT_CONFIG
606+
path = find_config(revision=revision)
521607
else:
522-
path = pathlib.Path(path) # enforce a cast to pathlib datatype
523-
with path.open() as f:
524-
d = toml.load(f)
525-
return DEFAULT_CONFIG.new_child(d)
608+
if ':' not in path:
609+
path = f'{head_sha}:{path}'
610+
611+
revision, _, path = path.partition(':')
612+
if not revision:
613+
revision = head_sha
614+
615+
config = DEFAULT_CONFIG
616+
617+
if path is not None:
618+
config_text = from_git_rev_read(path)
619+
d = toml.loads(config_text)
620+
config = config.new_child(d)
621+
622+
return path, config
623+
624+
625+
def get_sha1_from(commitish):
626+
cmd = ['git', 'rev-parse', commitish]
627+
return subprocess.check_output(cmd).strip().decode('utf-8')
628+
629+
630+
def set_paused_state():
631+
global chosen_config_path
632+
if chosen_config_path is not None:
633+
save_cfg_vals_to_git_cfg(config_path=chosen_config_path)
634+
set_state('BACKPORT_PAUSED')
635+
636+
637+
def reset_stored_config_ref():
638+
wipe_cfg_vals_from_git_cfg('config_path')
639+
640+
641+
def reset_state():
642+
wipe_cfg_vals_from_git_cfg('state')
643+
644+
645+
def set_state(state):
646+
save_cfg_vals_to_git_cfg(state=state)
647+
648+
649+
def get_state():
650+
return load_val_from_git_cfg('state') or 'UNSET'
651+
652+
653+
def save_cfg_vals_to_git_cfg(**cfg_map):
654+
for cfg_key_suffix, cfg_val in cfg_map.items():
655+
cfg_key = f'cherry-picker.{cfg_key_suffix.replace("_", "-")}'
656+
cmd = 'git', 'config', '--local', cfg_key, cfg_val
657+
subprocess.check_call(cmd, stderr=subprocess.STDOUT)
658+
659+
660+
def wipe_cfg_vals_from_git_cfg(*cfg_opts):
661+
for cfg_key_suffix in cfg_opts:
662+
cfg_key = f'cherry-picker.{cfg_key_suffix.replace("_", "-")}'
663+
cmd = 'git', 'config', '--local', '--unset-all', cfg_key
664+
subprocess.check_call(cmd, stderr=subprocess.STDOUT)
665+
666+
667+
def load_val_from_git_cfg(cfg_key_suffix):
668+
cfg_key = f'cherry-picker.{cfg_key_suffix.replace("_", "-")}'
669+
cmd = 'git', 'config', '--local', '--get', cfg_key
670+
try:
671+
return subprocess.check_output(cmd, stderr=subprocess.DEVNULL).strip().decode('utf-8')
672+
except subprocess.CalledProcessError:
673+
return None
674+
675+
676+
def from_git_rev_read(path):
677+
cmd = 'git', 'show', '-t', path
678+
return subprocess.check_output(cmd).rstrip().decode('utf-8')
526679

527680

528681
if __name__ == '__main__':

0 commit comments

Comments
 (0)
0