1010import shutil
1111import subprocess
1212import 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
1416from urllib .parse import unquote
1517
1618import nbformat
3840GIT_BRANCH_STATUS = re .compile (
3941 r"^## (?P<branch>([\w\-/]+|HEAD \(no branch\)|No commits yet on \w+))(\.\.\.(?P<remote>[\w\-/]+)( \[(ahead (?P<ahead>\d+))?(, )?(behind (?P<behind>\d+))?\])?)?$"
4042)
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>.+?)\)$" )
4147# Git cache as a credential helper
4248GIT_CREDENTIAL_HELPER_CACHE = re .compile (r"cache\b" )
4349
4450execution_lock = tornado .locks .Lock ()
4551
4652
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+
4776async def execute (
4877 cmdline : "List[str]" ,
4978 cwd : "str" ,
@@ -452,7 +481,7 @@ def remove_cell_ids(nb):
452481
453482 return {"base" : prev_nb , "diff" : thediff }
454483
455- async def status (self , path ) :
484+ async def status (self , path : str ) -> dict :
456485 """
457486 Execute git status command & return the result.
458487 """
@@ -528,6 +557,44 @@ async def status(self, path):
528557 except StopIteration : # Raised if line_iterable is empty
529558 pass
530559
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+
531598 return data
532599
533600 async def log (self , path , history_count = 10 , follow_path = None ):
@@ -720,6 +787,22 @@ async def branch(self, path):
720787 # error; bail
721788 return remotes
722789
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+
723806 # all's good; concatenate results and return
724807 return {
725808 "code" : 0 ,
@@ -1062,7 +1145,7 @@ async def checkout_all(self, path):
10621145 return {"code" : code , "command" : " " .join (cmd ), "message" : error }
10631146 return {"code" : code }
10641147
1065- async def merge (self , branch , path ) :
1148+ async def merge (self , branch : str , path : str ) -> dict :
10661149 """
10671150 Execute git merge command & return the result.
10681151 """
@@ -1253,7 +1336,7 @@ def _is_remote_branch(self, branch_reference):
12531336
12541337 async def get_current_branch (self , path ):
12551338 """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
12571340 to the `branch` command to get the name.
12581341 See https://git-blame.blogspot.com/2013/06/checking-current-branch-programatically.html
12591342 """
@@ -1272,7 +1355,7 @@ async def get_current_branch(self, path):
12721355 )
12731356
12741357 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,...). """
12761359 command = ["git" , "branch" , "-a" ]
12771360 code , output , error = await self .__execute (command , cwd = path )
12781361 if code == 0 :
@@ -1282,7 +1365,7 @@ async def _get_current_branch_detached(self, path):
12821365 return branch .lstrip ("* " )
12831366 else :
12841367 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 (
12861369 error , " " .join (command )
12871370 )
12881371 )
@@ -1805,6 +1888,42 @@ def ensure_git_credential_cache_daemon(
18051888 elif self ._GIT_CREDENTIAL_CACHE_DAEMON_PROCESS .poll ():
18061889 self .ensure_git_credential_cache_daemon (socket , debug , True , cwd , env )
18071890
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+
18081927 async def stash (self , path : str , stashMsg : str = "" ) -> dict :
18091928 """
18101929 Stash changes in a dirty working directory away
0 commit comments