10
10
import shutil
11
11
import subprocess
12
12
import traceback
13
- from typing import Dict , List , Optional
13
+ from enum import Enum , IntEnum
14
+ from pathlib import Path
15
+ from typing import Dict , List , Optional , Tuple
14
16
from urllib .parse import unquote
15
17
16
18
import nbformat
38
40
GIT_BRANCH_STATUS = re .compile (
39
41
r"^## (?P<branch>([\w\-/]+|HEAD \(no branch\)|No commits yet on \w+))(\.\.\.(?P<remote>[\w\-/]+)( \[(ahead (?P<ahead>\d+))?(, )?(behind (?P<behind>\d+))?\])?)?$"
40
42
)
43
+ # Parse Git detached head
44
+ GIT_DETACHED_HEAD = re .compile (r"^\(HEAD detached at (?P<commit>.+?)\)$" )
45
+ # Parse Git branch rebase name
46
+ GIT_REBASING_BRANCH = re .compile (r"^\(no branch, rebasing (?P<branch>.+?)\)$" )
41
47
# Git cache as a credential helper
42
48
GIT_CREDENTIAL_HELPER_CACHE = re .compile (r"cache\b" )
43
49
44
50
execution_lock = tornado .locks .Lock ()
45
51
46
52
53
+ class State (IntEnum ):
54
+ """Git repository state."""
55
+
56
+ # Default state
57
+ DEFAULT = (0 ,)
58
+ # Detached head state
59
+ DETACHED = (1 ,)
60
+ # Merge in progress
61
+ MERGING = (2 ,)
62
+ # Rebase in progress
63
+ REBASING = (3 ,)
64
+ # Cherry-pick in progress
65
+ CHERRY_PICKING = 4
66
+
67
+
68
+ class RebaseAction (Enum ):
69
+ """Git available action when rebasing."""
70
+
71
+ CONTINUE = 1
72
+ SKIP = 2
73
+ ABORT = 3
74
+
75
+
47
76
async def execute (
48
77
cmdline : "List[str]" ,
49
78
cwd : "str" ,
@@ -452,7 +481,7 @@ def remove_cell_ids(nb):
452
481
453
482
return {"base" : prev_nb , "diff" : thediff }
454
483
455
- async def status (self , path ) :
484
+ async def status (self , path : str ) -> dict :
456
485
"""
457
486
Execute git status command & return the result.
458
487
"""
@@ -528,6 +557,44 @@ async def status(self, path):
528
557
except StopIteration : # Raised if line_iterable is empty
529
558
pass
530
559
560
+ # Test for repository state
561
+ states = {
562
+ State .CHERRY_PICKING : "CHERRY_PICK_HEAD" ,
563
+ State .MERGING : "MERGE_HEAD" ,
564
+ # Looking at REBASE_HEAD is not reliable as it may not be clean in the .git folder
565
+ # e.g. when skipping the last commit of a ongoing rebase
566
+ # So looking for folder `rebase-apply` and `rebase-merge`; see https://stackoverflow.com/questions/3921409/how-to-know-if-there-is-a-git-rebase-in-progress
567
+ State .REBASING : ["rebase-merge" , "rebase-apply" ],
568
+ }
569
+
570
+ state = State .DEFAULT
571
+ for state_ , head in states .items ():
572
+ if isinstance (head , str ):
573
+ code , _ , _ = await self .__execute (
574
+ ["git" , "show" , "--quiet" , head ], cwd = path
575
+ )
576
+ if code == 0 :
577
+ state = state_
578
+ break
579
+ else :
580
+ found = False
581
+ for directory in head :
582
+ code , output , _ = await self .__execute (
583
+ ["git" , "rev-parse" , "--git-path" , directory ], cwd = path
584
+ )
585
+ filepath = output .strip ("\n \t " )
586
+ if code == 0 and (Path (path ) / filepath ).exists ():
587
+ found = True
588
+ state = state_
589
+ break
590
+ if found :
591
+ break
592
+
593
+ if state == State .DEFAULT and data ["branch" ] == "(detached)" :
594
+ state = State .DETACHED
595
+
596
+ data ["state" ] = state
597
+
531
598
return data
532
599
533
600
async def log (self , path , history_count = 10 , follow_path = None ):
@@ -720,6 +787,22 @@ async def branch(self, path):
720
787
# error; bail
721
788
return remotes
722
789
790
+ # Extract commit hash in case of detached head
791
+ is_detached = GIT_DETACHED_HEAD .match (heads ["current_branch" ]["name" ])
792
+ if is_detached is not None :
793
+ try :
794
+ heads ["current_branch" ]["name" ] = is_detached .groupdict ()["commit" ]
795
+ except KeyError :
796
+ pass
797
+ else :
798
+ # Extract branch name in case of rebasing
799
+ rebasing = GIT_REBASING_BRANCH .match (heads ["current_branch" ]["name" ])
800
+ if rebasing is not None :
801
+ try :
802
+ heads ["current_branch" ]["name" ] = rebasing .groupdict ()["branch" ]
803
+ except KeyError :
804
+ pass
805
+
723
806
# all's good; concatenate results and return
724
807
return {
725
808
"code" : 0 ,
@@ -1062,7 +1145,7 @@ async def checkout_all(self, path):
1062
1145
return {"code" : code , "command" : " " .join (cmd ), "message" : error }
1063
1146
return {"code" : code }
1064
1147
1065
- async def merge (self , branch , path ) :
1148
+ async def merge (self , branch : str , path : str ) -> dict :
1066
1149
"""
1067
1150
Execute git merge command & return the result.
1068
1151
"""
@@ -1253,7 +1336,7 @@ def _is_remote_branch(self, branch_reference):
1253
1336
1254
1337
async def get_current_branch (self , path ):
1255
1338
"""Use `symbolic-ref` to get the current branch name. In case of
1256
- failure, assume that the HEAD is currently detached, and fall back
1339
+ failure, assume that the HEAD is currently detached or rebasing , and fall back
1257
1340
to the `branch` command to get the name.
1258
1341
See https://git-blame.blogspot.com/2013/06/checking-current-branch-programatically.html
1259
1342
"""
@@ -1272,7 +1355,7 @@ async def get_current_branch(self, path):
1272
1355
)
1273
1356
1274
1357
async def _get_current_branch_detached (self , path ):
1275
- """Execute 'git branch -a' to get current branch details in case of detached HEAD """
1358
+ """Execute 'git branch -a' to get current branch details in case of dirty state (rebasing, detached head,...). """
1276
1359
command = ["git" , "branch" , "-a" ]
1277
1360
code , output , error = await self .__execute (command , cwd = path )
1278
1361
if code == 0 :
@@ -1282,7 +1365,7 @@ async def _get_current_branch_detached(self, path):
1282
1365
return branch .lstrip ("* " )
1283
1366
else :
1284
1367
raise Exception (
1285
- "Error [{}] occurred while executing [{}] command to get detached HEAD name ." .format (
1368
+ "Error [{}] occurred while executing [{}] command to get current state ." .format (
1286
1369
error , " " .join (command )
1287
1370
)
1288
1371
)
@@ -1805,6 +1888,42 @@ def ensure_git_credential_cache_daemon(
1805
1888
elif self ._GIT_CREDENTIAL_CACHE_DAEMON_PROCESS .poll ():
1806
1889
self .ensure_git_credential_cache_daemon (socket , debug , True , cwd , env )
1807
1890
1891
+ async def rebase (self , branch : str , path : str ) -> dict :
1892
+ """
1893
+ Execute git rebase command & return the result.
1894
+
1895
+ Args:
1896
+ branch: Branch to rebase onto
1897
+ path: Git repository path
1898
+ """
1899
+ cmd = ["git" , "rebase" , branch ]
1900
+ code , output , error = await execute (cmd , cwd = path )
1901
+
1902
+ if code != 0 :
1903
+ return {"code" : code , "command" : " " .join (cmd ), "message" : error }
1904
+ return {"code" : code , "message" : output .strip ()}
1905
+
1906
+ async def resolve_rebase (self , path : str , action : RebaseAction ) -> dict :
1907
+ """
1908
+ Execute git rebase --<action> command & return the result.
1909
+
1910
+ Args:
1911
+ path: Git repository path
1912
+ """
1913
+ option = action .name .lower ()
1914
+ cmd = ["git" , "rebase" , f"--{ option } " ]
1915
+ env = None
1916
+ # For continue we force the editor to not show up
1917
+ # Ref: https://stackoverflow.com/questions/43489971/how-to-suppress-the-editor-for-git-rebase-continue
1918
+ if option == "continue" :
1919
+ env = os .environ .copy ()
1920
+ env ["GIT_EDITOR" ] = "true"
1921
+ code , output , error = await execute (cmd , cwd = path , env = env )
1922
+
1923
+ if code != 0 :
1924
+ return {"code" : code , "command" : " " .join (cmd ), "message" : error }
1925
+ return {"code" : code , "message" : output .strip ()}
1926
+
1808
1927
async def stash (self , path : str , stashMsg : str = "" ) -> dict :
1809
1928
"""
1810
1929
Stash changes in a dirty working directory away
0 commit comments