Skip to content

Commit c941ee9

Browse files
authored
Add support for rebase (#1260)
* Add git repository state * Handle conflicts for rebase/cherry-pick/merge * Add rebase command * Add some integration tests * Lint * Don't add the "current branch" if the repo is not in default state * Fix switch from a detached head * Use custom buttons instead of commit box when rebasing * More robust rebase detection * Improve styling * Display notification when resolving rebase * Improve tests * Lint the code * Enable resolve rebase command only if the repo is in rebase * Fix current tests * Lint code * Fix unit test
1 parent c639273 commit c941ee9

21 files changed

+1287
-131
lines changed

jupyterlab_git/git.py

Lines changed: 125 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
import shutil
1111
import subprocess
1212
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
1416
from urllib.parse import unquote
1517

1618
import nbformat
@@ -38,12 +40,39 @@
3840
GIT_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
4248
GIT_CREDENTIAL_HELPER_CACHE = re.compile(r"cache\b")
4349

4450
execution_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+
4776
async 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

jupyterlab_git/handlers.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
hybridcontents = None
2121

2222
from ._version import __version__
23-
from .git import DEFAULT_REMOTE_NAME, Git
23+
from .git import DEFAULT_REMOTE_NAME, Git, RebaseAction
2424
from .log import get_logger
2525

2626
# Git configuration options exposed through the REST API
@@ -892,6 +892,36 @@ async def post(self, path: str = ""):
892892
self.finish(json.dumps(result))
893893

894894

895+
class GitRebaseHandler(GitHandler):
896+
"""
897+
Handler for git rebase '<rebase_onto>'.
898+
"""
899+
900+
@tornado.web.authenticated
901+
async def post(self, path: str = ""):
902+
"""
903+
POST request handler, rebase the current branch
904+
"""
905+
data = self.get_json_body()
906+
branch = data.get("branch")
907+
action = data.get("action", "")
908+
if branch is not None:
909+
body = await self.git.rebase(branch, self.url2localpath(path))
910+
else:
911+
try:
912+
body = await self.git.resolve_rebase(
913+
self.url2localpath(path), RebaseAction[action.upper()]
914+
)
915+
except KeyError:
916+
raise tornado.web.HTTPError(
917+
status_code=404, reason=f"Unknown action '{action}'"
918+
)
919+
920+
if body["code"] != 0:
921+
self.set_status(500)
922+
self.finish(json.dumps(body))
923+
924+
895925
class GitStashHandler(GitHandler):
896926
"""
897927
Handler for 'git stash'. Stores the changes in the current branch
@@ -1037,6 +1067,7 @@ def setup_handlers(web_app):
10371067
("/tags", GitTagHandler),
10381068
("/tag_checkout", GitTagCheckoutHandler),
10391069
("/add", GitAddHandler),
1070+
("/rebase", GitRebaseHandler),
10401071
("/stash", GitStashHandler),
10411072
("/stash_pop", GitStashPopHandler),
10421073
("/stash_apply", GitStashApplyHandler),

0 commit comments

Comments
 (0)