4
4
import click
5
5
import collections
6
6
import os
7
- import pathlib
8
7
import subprocess
9
8
import webbrowser
10
9
import re
16
15
17
16
from . import __version__
18
17
18
+
19
+ chosen_config_path = None
20
+
19
21
CREATE_PR_URL_TEMPLATE = ("https://api.github.com/repos/"
20
22
"{config[team]}/{config[repo]}/pulls" )
21
23
DEFAULT_CONFIG = collections .ChainMap ({
@@ -41,6 +43,11 @@ class InvalidRepoException(Exception):
41
43
42
44
class CherryPicker :
43
45
46
+ ALLOWED_STATES = (
47
+ 'BACKPORT_PAUSED' ,
48
+ 'UNSET' ,
49
+ )
50
+
44
51
def __init__ (self , pr_remote , commit_sha1 , branches ,
45
52
* , dry_run = False , push = True ,
46
53
prefix_commit = True ,
@@ -50,6 +57,8 @@ def __init__(self, pr_remote, commit_sha1, branches,
50
57
self .config = config
51
58
self .check_repo () # may raise InvalidRepoException
52
59
60
+ self .initial_state = self .get_state_and_verify ()
61
+
53
62
if dry_run :
54
63
click .echo ("Dry run requested, listing expected command sequence" )
55
64
@@ -97,8 +106,10 @@ def get_pr_url(self, base_branch, head_branch):
97
106
98
107
def fetch_upstream (self ):
99
108
""" git fetch <upstream> """
109
+ set_state ('FETCHING_UPSTREAM' )
100
110
cmd = ['git' , 'fetch' , self .upstream ]
101
111
self .run_cmd (cmd )
112
+ set_state ('FETCHED_UPSTREAM' )
102
113
103
114
def run_cmd (self , cmd ):
104
115
assert not isinstance (cmd , str )
@@ -133,10 +144,13 @@ def get_commit_message(self, commit_sha):
133
144
134
145
def checkout_default_branch (self ):
135
146
""" git checkout default branch """
147
+ set_state ('CHECKING_OUT_DEFAULT_BRANCH' )
136
148
137
149
cmd = 'git' , 'checkout' , self .config ['default_branch' ]
138
150
self .run_cmd (cmd )
139
151
152
+ set_state ('CHECKED_OUT_DEFAULT_BRANCH' )
153
+
140
154
def status (self ):
141
155
"""
142
156
git status
@@ -196,19 +210,24 @@ def amend_commit_message(self, cherry_pick_branch):
196
210
197
211
def push_to_remote (self , base_branch , head_branch , commit_message = "" ):
198
212
""" git push <origin> <branchname> """
213
+ set_state ('PUSHING_TO_REMOTE' )
199
214
200
215
cmd = ['git' , 'push' , self .pr_remote , f'{ head_branch } :{ head_branch } ' ]
201
216
try :
202
217
self .run_cmd (cmd )
218
+ set_state ('PUSHED_TO_REMOTE' )
203
219
except subprocess .CalledProcessError :
204
220
click .echo (f"Failed to push to { self .pr_remote } \u2639 " )
221
+ set_state ('PUSHING_TO_REMOTE_FAILED' )
205
222
else :
206
223
gh_auth = os .getenv ("GH_AUTH" )
207
224
if gh_auth :
225
+ set_state ('PR_CREATING' )
208
226
self .create_gh_pr (base_branch , head_branch ,
209
227
commit_message = commit_message ,
210
228
gh_auth = gh_auth )
211
229
else :
230
+ set_state ('PR_OPENING' )
212
231
self .open_pr (self .get_pr_url (base_branch , head_branch ))
213
232
214
233
def create_gh_pr (self , base_branch , head_branch , * ,
@@ -253,20 +272,26 @@ def delete_branch(self, branch):
253
272
self .run_cmd (cmd )
254
273
255
274
def cleanup_branch (self , branch ):
275
+ set_state ('REMOVING_BACKPORT_BRANCH' )
256
276
self .checkout_default_branch ()
257
277
try :
258
278
self .delete_branch (branch )
259
279
except subprocess .CalledProcessError :
260
280
click .echo (f"branch { branch } NOT deleted." )
281
+ set_state ('REMOVING_BACKPORT_BRANCH_FAILED' )
261
282
else :
262
283
click .echo (f"branch { branch } has been deleted." )
284
+ set_state ('REMOVED_BACKPORT_BRANCH' )
263
285
264
286
def backport (self ):
265
287
if not self .branches :
266
288
raise click .UsageError ("At least one branch must be specified." )
289
+ set_state ('BACKPORT_STARTING' )
267
290
self .fetch_upstream ()
268
291
292
+ set_state ('BACKPORT_LOOPING' )
269
293
for maint_branch in self .sorted_branches :
294
+ set_state ('BACKPORT_LOOP_START' )
270
295
click .echo (f"Now backporting '{ self .commit_sha1 } ' into '{ maint_branch } '" )
271
296
272
297
cherry_pick_branch = self .get_cherry_pick_branch (maint_branch )
@@ -280,6 +305,7 @@ def backport(self):
280
305
click .echo (self .get_exit_message (maint_branch ))
281
306
except CherryPickException :
282
307
click .echo (self .get_exit_message (maint_branch ))
308
+ set_paused_state ()
283
309
raise
284
310
else :
285
311
if self .push :
@@ -299,28 +325,44 @@ def backport(self):
299
325
To abort the cherry-pick and cleanup:
300
326
$ cherry_picker --abort
301
327
""" )
328
+ set_paused_state ()
329
+ set_state ('BACKPORT_LOOP_END' )
330
+ set_state ('BACKPORT_COMPLETE' )
302
331
303
332
def abort_cherry_pick (self ):
304
333
"""
305
334
run `git cherry-pick --abort` and then clean up the branch
306
335
"""
336
+ if self .initial_state != 'BACKPORT_PAUSED' :
337
+ raise ValueError ('One can only abort a paused process.' )
338
+
307
339
cmd = ['git' , 'cherry-pick' , '--abort' ]
308
340
try :
341
+ set_state ('ABORTING' )
309
342
self .run_cmd (cmd )
343
+ set_state ('ABORTED' )
310
344
except subprocess .CalledProcessError as cpe :
311
345
click .echo (cpe .output )
346
+ set_state ('ABORTING_FAILED' )
312
347
# only delete backport branch created by cherry_picker.py
313
348
if get_current_branch ().startswith ('backport-' ):
314
349
self .cleanup_branch (get_current_branch ())
315
350
351
+ reset_stored_config_ref ()
352
+ reset_state ()
353
+
316
354
def continue_cherry_pick (self ):
317
355
"""
318
356
git push origin <current_branch>
319
357
open the PR
320
358
clean up branch
321
359
"""
360
+ if self .initial_state != 'BACKPORT_PAUSED' :
361
+ raise ValueError ('One can only continue a paused process.' )
362
+
322
363
cherry_pick_branch = get_current_branch ()
323
364
if cherry_pick_branch .startswith ('backport-' ):
365
+ set_state ('CONTINUATION_STARTED' )
324
366
# amend the commit message, prefix with [X.Y]
325
367
base = get_base_branch (cherry_pick_branch )
326
368
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):
344
386
345
387
click .echo ("\n Backport PR:\n " )
346
388
click .echo (updated_commit_message )
389
+ set_state ('BACKPORTING_CONTINUATION_SUCCEED' )
347
390
348
391
else :
349
392
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 ()
350
397
351
398
def check_repo (self ):
352
399
"""
@@ -360,6 +407,22 @@ def check_repo(self):
360
407
except ValueError :
361
408
raise InvalidRepoException ()
362
409
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.\n Perhaps 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
+
363
426
364
427
CONTEXT_SETTINGS = dict (help_option_names = ['-h' , '--help' ])
365
428
@@ -379,17 +442,20 @@ def check_repo(self):
379
442
help = "Changes won't be pushed to remote" )
380
443
@click .option ('--config-path' , 'config_path' , metavar = 'CONFIG-PATH' ,
381
444
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." ),
383
447
default = None )
384
448
@click .argument ('commit_sha1' , 'The commit sha1 to be cherry-picked' , nargs = 1 ,
385
449
default = "" )
386
450
@click .argument ('branches' , 'The branches to backport to' , nargs = - 1 )
387
451
def cherry_pick_cli (dry_run , pr_remote , abort , status , push , config_path ,
388
452
commit_sha1 , branches ):
453
+ ctx = click .get_current_context ()
389
454
390
455
click .echo ("\U0001F40D \U0001F352 \u26CF " )
391
456
392
- config = load_config (config_path )
457
+ global chosen_config_path
458
+ chosen_config_path , config = load_config (config_path )
393
459
394
460
try :
395
461
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,
398
464
except InvalidRepoException :
399
465
click .echo (f"You're not inside a { config ['repo' ]} repo right now! \U0001F645 " )
400
466
sys .exit (- 1 )
467
+ except ValueError as exc :
468
+ ctx .fail (exc )
401
469
402
470
if abort is not None :
403
471
if abort :
@@ -498,31 +566,116 @@ def normalize_commit_message(commit_message):
498
566
return title , body .lstrip ("\n " )
499
567
500
568
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
505
584
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
506
591
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
513
592
return None
514
593
515
594
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
+
519
605
if path is None :
520
- return DEFAULT_CONFIG
606
+ path = find_config ( revision = revision )
521
607
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' )
526
679
527
680
528
681
if __name__ == '__main__' :
0 commit comments