diff --git a/jupyterlab_git/git.py b/jupyterlab_git/git.py index 3e30f8067..336f37bae 100644 --- a/jupyterlab_git/git.py +++ b/jupyterlab_git/git.py @@ -10,7 +10,9 @@ import shutil import subprocess import traceback -from typing import Dict, List, Optional +from enum import Enum, IntEnum +from pathlib import Path +from typing import Dict, List, Optional, Tuple from urllib.parse import unquote import nbformat @@ -38,12 +40,39 @@ GIT_BRANCH_STATUS = re.compile( r"^## (?P([\w\-/]+|HEAD \(no branch\)|No commits yet on \w+))(\.\.\.(?P[\w\-/]+)( \[(ahead (?P\d+))?(, )?(behind (?P\d+))?\])?)?$" ) +# Parse Git detached head +GIT_DETACHED_HEAD = re.compile(r"^\(HEAD detached at (?P.+?)\)$") +# Parse Git branch rebase name +GIT_REBASING_BRANCH = re.compile(r"^\(no branch, rebasing (?P.+?)\)$") # Git cache as a credential helper GIT_CREDENTIAL_HELPER_CACHE = re.compile(r"cache\b") execution_lock = tornado.locks.Lock() +class State(IntEnum): + """Git repository state.""" + + # Default state + DEFAULT = (0,) + # Detached head state + DETACHED = (1,) + # Merge in progress + MERGING = (2,) + # Rebase in progress + REBASING = (3,) + # Cherry-pick in progress + CHERRY_PICKING = 4 + + +class RebaseAction(Enum): + """Git available action when rebasing.""" + + CONTINUE = 1 + SKIP = 2 + ABORT = 3 + + async def execute( cmdline: "List[str]", cwd: "str", @@ -452,7 +481,7 @@ def remove_cell_ids(nb): return {"base": prev_nb, "diff": thediff} - async def status(self, path): + async def status(self, path: str) -> dict: """ Execute git status command & return the result. """ @@ -528,6 +557,44 @@ async def status(self, path): except StopIteration: # Raised if line_iterable is empty pass + # Test for repository state + states = { + State.CHERRY_PICKING: "CHERRY_PICK_HEAD", + State.MERGING: "MERGE_HEAD", + # Looking at REBASE_HEAD is not reliable as it may not be clean in the .git folder + # e.g. when skipping the last commit of a ongoing rebase + # 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 + State.REBASING: ["rebase-merge", "rebase-apply"], + } + + state = State.DEFAULT + for state_, head in states.items(): + if isinstance(head, str): + code, _, _ = await self.__execute( + ["git", "show", "--quiet", head], cwd=path + ) + if code == 0: + state = state_ + break + else: + found = False + for directory in head: + code, output, _ = await self.__execute( + ["git", "rev-parse", "--git-path", directory], cwd=path + ) + filepath = output.strip("\n\t ") + if code == 0 and (Path(path) / filepath).exists(): + found = True + state = state_ + break + if found: + break + + if state == State.DEFAULT and data["branch"] == "(detached)": + state = State.DETACHED + + data["state"] = state + return data async def log(self, path, history_count=10, follow_path=None): @@ -720,6 +787,22 @@ async def branch(self, path): # error; bail return remotes + # Extract commit hash in case of detached head + is_detached = GIT_DETACHED_HEAD.match(heads["current_branch"]["name"]) + if is_detached is not None: + try: + heads["current_branch"]["name"] = is_detached.groupdict()["commit"] + except KeyError: + pass + else: + # Extract branch name in case of rebasing + rebasing = GIT_REBASING_BRANCH.match(heads["current_branch"]["name"]) + if rebasing is not None: + try: + heads["current_branch"]["name"] = rebasing.groupdict()["branch"] + except KeyError: + pass + # all's good; concatenate results and return return { "code": 0, @@ -1062,7 +1145,7 @@ async def checkout_all(self, path): return {"code": code, "command": " ".join(cmd), "message": error} return {"code": code} - async def merge(self, branch, path): + async def merge(self, branch: str, path: str) -> dict: """ Execute git merge command & return the result. """ @@ -1253,7 +1336,7 @@ def _is_remote_branch(self, branch_reference): async def get_current_branch(self, path): """Use `symbolic-ref` to get the current branch name. In case of - failure, assume that the HEAD is currently detached, and fall back + failure, assume that the HEAD is currently detached or rebasing, and fall back to the `branch` command to get the name. See https://git-blame.blogspot.com/2013/06/checking-current-branch-programatically.html """ @@ -1272,7 +1355,7 @@ async def get_current_branch(self, path): ) async def _get_current_branch_detached(self, path): - """Execute 'git branch -a' to get current branch details in case of detached HEAD""" + """Execute 'git branch -a' to get current branch details in case of dirty state (rebasing, detached head,...).""" command = ["git", "branch", "-a"] code, output, error = await self.__execute(command, cwd=path) if code == 0: @@ -1282,7 +1365,7 @@ async def _get_current_branch_detached(self, path): return branch.lstrip("* ") else: raise Exception( - "Error [{}] occurred while executing [{}] command to get detached HEAD name.".format( + "Error [{}] occurred while executing [{}] command to get current state.".format( error, " ".join(command) ) ) @@ -1805,6 +1888,42 @@ def ensure_git_credential_cache_daemon( elif self._GIT_CREDENTIAL_CACHE_DAEMON_PROCESS.poll(): self.ensure_git_credential_cache_daemon(socket, debug, True, cwd, env) + async def rebase(self, branch: str, path: str) -> dict: + """ + Execute git rebase command & return the result. + + Args: + branch: Branch to rebase onto + path: Git repository path + """ + cmd = ["git", "rebase", branch] + code, output, error = await execute(cmd, cwd=path) + + if code != 0: + return {"code": code, "command": " ".join(cmd), "message": error} + return {"code": code, "message": output.strip()} + + async def resolve_rebase(self, path: str, action: RebaseAction) -> dict: + """ + Execute git rebase -- command & return the result. + + Args: + path: Git repository path + """ + option = action.name.lower() + cmd = ["git", "rebase", f"--{option}"] + env = None + # For continue we force the editor to not show up + # Ref: https://stackoverflow.com/questions/43489971/how-to-suppress-the-editor-for-git-rebase-continue + if option == "continue": + env = os.environ.copy() + env["GIT_EDITOR"] = "true" + code, output, error = await execute(cmd, cwd=path, env=env) + + if code != 0: + return {"code": code, "command": " ".join(cmd), "message": error} + return {"code": code, "message": output.strip()} + async def stash(self, path: str, stashMsg: str = "") -> dict: """ Stash changes in a dirty working directory away diff --git a/jupyterlab_git/handlers.py b/jupyterlab_git/handlers.py index 5a9a3d6d1..3cb3c57e4 100644 --- a/jupyterlab_git/handlers.py +++ b/jupyterlab_git/handlers.py @@ -20,7 +20,7 @@ hybridcontents = None from ._version import __version__ -from .git import DEFAULT_REMOTE_NAME, Git +from .git import DEFAULT_REMOTE_NAME, Git, RebaseAction from .log import get_logger # Git configuration options exposed through the REST API @@ -892,6 +892,36 @@ async def post(self, path: str = ""): self.finish(json.dumps(result)) +class GitRebaseHandler(GitHandler): + """ + Handler for git rebase ''. + """ + + @tornado.web.authenticated + async def post(self, path: str = ""): + """ + POST request handler, rebase the current branch + """ + data = self.get_json_body() + branch = data.get("branch") + action = data.get("action", "") + if branch is not None: + body = await self.git.rebase(branch, self.url2localpath(path)) + else: + try: + body = await self.git.resolve_rebase( + self.url2localpath(path), RebaseAction[action.upper()] + ) + except KeyError: + raise tornado.web.HTTPError( + status_code=404, reason=f"Unknown action '{action}'" + ) + + if body["code"] != 0: + self.set_status(500) + self.finish(json.dumps(body)) + + class GitStashHandler(GitHandler): """ Handler for 'git stash'. Stores the changes in the current branch @@ -1037,6 +1067,7 @@ def setup_handlers(web_app): ("/tags", GitTagHandler), ("/tag_checkout", GitTagCheckoutHandler), ("/add", GitAddHandler), + ("/rebase", GitRebaseHandler), ("/stash", GitStashHandler), ("/stash_pop", GitStashPopHandler), ("/stash_apply", GitStashApplyHandler), diff --git a/jupyterlab_git/tests/test_branch.py b/jupyterlab_git/tests/test_branch.py index 2d71df562..c78d62e66 100644 --- a/jupyterlab_git/tests/test_branch.py +++ b/jupyterlab_git/tests/test_branch.py @@ -457,7 +457,7 @@ async def test_get_current_branch_detached_failure(): ) assert ( "Error [fatal: Not a git repository (or any of the parent directories): .git] " - "occurred while executing [git branch -a] command to get detached HEAD name." + "occurred while executing [git branch -a] command to get current state." == str(error.value) ) @@ -891,7 +891,7 @@ async def test_branch_success_detached_head(): { "is_current_branch": True, "is_remote_branch": False, - "name": "(HEAD detached at origin/feature-foo)", + "name": "origin/feature-foo", "upstream": None, "top_commit": None, "tag": None, @@ -908,7 +908,145 @@ async def test_branch_success_detached_head(): "current_branch": { "is_current_branch": True, "is_remote_branch": False, - "name": "(HEAD detached at origin/feature-foo)", + "name": "origin/feature-foo", + "upstream": None, + "top_commit": None, + "tag": None, + }, + } + + # When + actual_response = await Git().branch(path=str(Path("/bin/test_curr_path"))) + + # Then + mock_execute.assert_has_calls( + [ + # call to get refs/heads + call( + [ + "git", + "for-each-ref", + "--format=%(refname:short)%09%(objectname)%09%(upstream:short)%09%(HEAD)", + "refs/heads/", + ], + cwd=str(Path("/bin") / "test_curr_path"), + timeout=20, + env=None, + username=None, + password=None, + is_binary=False, + ), + # call to get current branch + call( + ["git", "symbolic-ref", "--short", "HEAD"], + cwd=str(Path("/bin") / "test_curr_path"), + timeout=20, + env=None, + username=None, + password=None, + is_binary=False, + ), + # call to get current branch name given a detached head + call( + ["git", "branch", "-a"], + cwd=str(Path("/bin") / "test_curr_path"), + timeout=20, + env=None, + username=None, + password=None, + is_binary=False, + ), + # call to get refs/remotes + call( + [ + "git", + "for-each-ref", + "--format=%(refname:short)%09%(objectname)", + "refs/remotes/", + ], + cwd=str(Path("/bin") / "test_curr_path"), + timeout=20, + env=None, + username=None, + password=None, + is_binary=False, + ), + ], + any_order=False, + ) + + assert expected_response == actual_response + + +@pytest.mark.asyncio +async def test_branch_success_rebasing(): + with patch("jupyterlab_git.git.execute") as mock_execute: + # Given + process_output_heads = [ + "main\tabcdefghijklmnopqrstuvwxyz01234567890123\torigin/main\t ", + "feature-foo\tabcdefghijklmnopqrstuvwxyz01234567890123\torigin/feature-foo\t ", + ] + process_output_remotes = [ + "origin/feature-foo\tabcdefghijklmnopqrstuvwxyz01234567890123" + ] + detached_head_output = [ + "* (no branch, rebasing feature-foo)", + " main", + " feature-foo", + " remotes/origin/feature-foo", + ] + + mock_execute.side_effect = [ + # Response for get all refs/heads + maybe_future((0, "\n".join(process_output_heads), "")), + # Response for get current branch + maybe_future((128, "", "fatal: ref HEAD is not a symbolic ref")), + # Response for get current branch detached + maybe_future((0, "\n".join(detached_head_output), "")), + # Response for get all refs/remotes + maybe_future((0, "\n".join(process_output_remotes), "")), + ] + + expected_response = { + "code": 0, + "branches": [ + { + "is_current_branch": False, + "is_remote_branch": False, + "name": "main", + "upstream": "origin/main", + "top_commit": "abcdefghijklmnopqrstuvwxyz01234567890123", + "tag": None, + }, + { + "is_current_branch": False, + "is_remote_branch": False, + "name": "feature-foo", + "upstream": "origin/feature-foo", + "top_commit": "abcdefghijklmnopqrstuvwxyz01234567890123", + "tag": None, + }, + { + "is_current_branch": True, + "is_remote_branch": False, + "name": "feature-foo", + "upstream": None, + "top_commit": None, + "tag": None, + }, + { + "is_current_branch": False, + "is_remote_branch": True, + "name": "origin/feature-foo", + "upstream": None, + "top_commit": "abcdefghijklmnopqrstuvwxyz01234567890123", + "tag": None, + }, + ], + "current_branch": { + "is_current_branch": True, + "is_remote_branch": False, + "name": "feature-foo", "upstream": None, "top_commit": None, "tag": None, diff --git a/jupyterlab_git/tests/test_status.py b/jupyterlab_git/tests/test_status.py index e41a9ea9e..e531b5152 100644 --- a/jupyterlab_git/tests/test_status.py +++ b/jupyterlab_git/tests/test_status.py @@ -33,6 +33,7 @@ "remote": None, "ahead": 0, "behind": 0, + "state": 0, "files": [ { "x": "A", @@ -82,6 +83,7 @@ "remote": None, "ahead": 0, "behind": 0, + "state": 0, "files": [], }, ), @@ -95,6 +97,7 @@ "remote": "origin/main", "ahead": 0, "behind": 0, + "state": 0, "files": [], }, ), @@ -108,6 +111,7 @@ "remote": "origin/main", "ahead": 15, "behind": 0, + "state": 0, "files": [], }, ), @@ -121,6 +125,7 @@ "remote": "origin/main", "ahead": 0, "behind": 5, + "state": 0, "files": [], }, ), @@ -134,6 +139,7 @@ "remote": "origin/main", "ahead": 3, "behind": 5, + "state": 0, "files": [], }, ), @@ -147,6 +153,7 @@ "remote": None, "ahead": 0, "behind": 0, + "state": 0, "files": [], }, ), @@ -160,52 +167,280 @@ "remote": None, "ahead": 0, "behind": 0, + "state": 1, "files": [], }, ), + # Cherry pick + ( + ( + "## master", + "UD another_file.txt", + "A branch_file.py", + "UU example.ipynb", + "UU file.txt", + ), + ( + "1\t0\t.gitignore", + "0\t0\tanother_file.txt", + "21\t0\tbranch_file.py", + "0\t0\texample.ipynb", + "0\t0\tfile.txt", + "-\t-\tgit_workflow.jpg", + "-\t-\tjupyter.png", + "16\t0\tmaster_file.ts", + ), + { + "code": 0, + "branch": "master", + "remote": None, + "ahead": 0, + "behind": 0, + "files": [ + { + "x": "U", + "y": "D", + "to": "another_file.txt", + "from": "another_file.txt", + "is_binary": False, + }, + { + "x": "A", + "y": " ", + "to": "branch_file.py", + "from": "branch_file.py", + "is_binary": False, + }, + { + "x": "U", + "y": "U", + "to": "example.ipynb", + "from": "example.ipynb", + "is_binary": False, + }, + { + "x": "U", + "y": "U", + "to": "file.txt", + "from": "file.txt", + "is_binary": False, + }, + ], + "state": 4, + }, + ), + # Rebasing + ( + ( + "## master", + "UD another_file.txt", + "A branch_file.py", + "UU example.ipynb", + "UU file.txt", + ), + ( + "1\t0\t.gitignore", + "0\t0\tanother_file.txt", + "21\t0\tbranch_file.py", + "0\t0\texample.ipynb", + "0\t0\tfile.txt", + "-\t-\tgit_workflow.jpg", + "-\t-\tjupyter.png", + "16\t0\tmaster_file.ts", + ), + { + "code": 0, + "branch": "master", + "remote": None, + "ahead": 0, + "behind": 0, + "files": [ + { + "x": "U", + "y": "D", + "to": "another_file.txt", + "from": "another_file.txt", + "is_binary": False, + }, + { + "x": "A", + "y": " ", + "to": "branch_file.py", + "from": "branch_file.py", + "is_binary": False, + }, + { + "x": "U", + "y": "U", + "to": "example.ipynb", + "from": "example.ipynb", + "is_binary": False, + }, + { + "x": "U", + "y": "U", + "to": "file.txt", + "from": "file.txt", + "is_binary": False, + }, + ], + "state": 3, + }, + ), + # Merging + ( + ( + "## master", + "UD another_file.txt", + "A branch_file.py", + "UU example.ipynb", + "UU file.txt", + ), + ( + "1\t0\t.gitignore", + "0\t0\tanother_file.txt", + "21\t0\tbranch_file.py", + "0\t0\texample.ipynb", + "0\t0\tfile.txt", + "-\t-\tgit_workflow.jpg", + "-\t-\tjupyter.png", + "16\t0\tmaster_file.ts", + ), + { + "code": 0, + "branch": "master", + "remote": None, + "ahead": 0, + "behind": 0, + "files": [ + { + "x": "U", + "y": "D", + "to": "another_file.txt", + "from": "another_file.txt", + "is_binary": False, + }, + { + "x": "A", + "y": " ", + "to": "branch_file.py", + "from": "branch_file.py", + "is_binary": False, + }, + { + "x": "U", + "y": "U", + "to": "example.ipynb", + "from": "example.ipynb", + "is_binary": False, + }, + { + "x": "U", + "y": "U", + "to": "file.txt", + "from": "file.txt", + "is_binary": False, + }, + ], + "state": 0, + }, + ), ], ) -async def test_status(output, diff_output, expected): +async def test_status(tmp_path, output, diff_output, expected): with patch("jupyterlab_git.git.execute") as mock_execute: # Given - repository = "test_curr_path" + repository = tmp_path / "test_curr_path" + (repository / ".git" / "rebase-merge").mkdir(parents=True) + mock_execute.side_effect = [ maybe_future((0, "\x00".join(output) + "\x00", "")), maybe_future((0, "\x00".join(diff_output) + "\x00", "")), + maybe_future((0 if expected["state"] == 4 else 128, "", "cherry pick")), + maybe_future((0 if expected["state"] == 2 else 128, "", "merge")), + maybe_future( + (0 if expected["state"] == 3 else 128, ".git/rebase-merge", "rebase") + ), + maybe_future( + (0 if expected["state"] == 3 else 128, ".git/rebase-apply", "rebase") + ), ] # When - actual_response = await Git().status(path=repository) + actual_response = await Git().status(path=str(repository)) # Then - mock_execute.assert_has_calls( - [ - call( - ["git", "status", "--porcelain", "-b", "-u", "-z"], - cwd=repository, - timeout=20, - env=None, - username=None, - password=None, - is_binary=False, - ), - call( - [ - "git", - "diff", - "--numstat", - "-z", - "--cached", - "4b825dc642cb6eb9a060e54bf8d69288fbee4904", - ], - cwd=repository, - timeout=20, - env=None, - username=None, - password=None, - is_binary=False, - ), - ] - ) + expected_calls = [ + call( + ["git", "status", "--porcelain", "-b", "-u", "-z"], + cwd=str(repository), + timeout=20, + env=None, + username=None, + password=None, + is_binary=False, + ), + call( + [ + "git", + "diff", + "--numstat", + "-z", + "--cached", + "4b825dc642cb6eb9a060e54bf8d69288fbee4904", + ], + cwd=str(repository), + timeout=20, + env=None, + username=None, + password=None, + is_binary=False, + ), + call( + ["git", "show", "--quiet", "CHERRY_PICK_HEAD"], + cwd=str(repository), + timeout=20, + env=None, + username=None, + password=None, + is_binary=False, + ), + call( + ["git", "show", "--quiet", "MERGE_HEAD"], + cwd=str(repository), + timeout=20, + env=None, + username=None, + password=None, + is_binary=False, + ), + call( + ["git", "rev-parse", "--git-path", "rebase-merge"], + cwd=str(repository), + timeout=20, + env=None, + username=None, + password=None, + is_binary=False, + ), + call( + ["git", "rev-parse", "--git-path", "rebase-apply"], + cwd=str(repository), + timeout=20, + env=None, + username=None, + password=None, + is_binary=False, + ), + ] + + if expected["state"] == 4: + expected_calls = expected_calls[:-3] + elif expected["state"] == 2: + expected_calls = expected_calls[:-2] + elif expected["state"] == 3: + expected_calls = expected_calls[:-1] + + mock_execute.assert_has_calls(expected_calls) assert expected == actual_response diff --git a/src/__tests__/test-components/GitPanel.spec.tsx b/src/__tests__/test-components/GitPanel.spec.tsx index 785b96c47..1b1a3b36e 100644 --- a/src/__tests__/test-components/GitPanel.spec.tsx +++ b/src/__tests__/test-components/GitPanel.spec.tsx @@ -237,6 +237,7 @@ describe('GitPanel', () => { } as any; props.model = { branches: [], + status: {}, stashChanged: { connect: jest.fn() }, diff --git a/src/commandsAndMenu.tsx b/src/commandsAndMenu.tsx index a8927fff2..e2920d18d 100644 --- a/src/commandsAndMenu.tsx +++ b/src/commandsAndMenu.tsx @@ -1,6 +1,7 @@ import { JupyterFrontEnd } from '@jupyterlab/application'; import { Dialog, + InputDialog, MainAreaWidget, ReactWidget, showDialog, @@ -15,19 +16,21 @@ import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { ITerminal } from '@jupyterlab/terminal'; import { ITranslator, TranslationBundle } from '@jupyterlab/translation'; import { closeIcon, ContextMenuSvg } from '@jupyterlab/ui-components'; -import { ArrayExt, toArray, find } from '@lumino/algorithm'; +import { ArrayExt, find, toArray } from '@lumino/algorithm'; import { CommandRegistry } from '@lumino/commands'; import { PromiseDelegate } from '@lumino/coreutils'; import { Message } from '@lumino/messaging'; import { ContextMenu, DockPanel, Menu, Panel, Widget } from '@lumino/widgets'; import * as React from 'react'; +import { CancelledError } from './cancelledError'; +import { BranchPicker } from './components/BranchPicker'; import { DiffModel } from './components/diff/model'; import { createPlainTextDiff } from './components/diff/PlainTextDiff'; +import { PreviewMainAreaWidget } from './components/diff/PreviewMainAreaWidget'; import { CONTEXT_COMMANDS } from './components/FileList'; -import { MergeBranchDialog } from './components/MergeBranchDialog'; +import { ManageRemoteDialogue } from './components/ManageRemoteDialogue'; import { AUTH_ERROR_MESSAGES, requestAPI } from './git'; import { logger } from './logger'; -import { CancelledError } from './cancelledError'; import { getDiffProvider, GitExtension } from './model'; import { addIcon, @@ -45,13 +48,10 @@ import { IGitExtension, Level } from './tokens'; +import { AdvancedPushForm } from './widgets/AdvancedPushForm'; import { GitCredentialsForm } from './widgets/CredentialsBox'; import { discardAllChanges } from './widgets/discardAllChanges'; -import { ManageRemoteDialogue } from './components/ManageRemoteDialogue'; import { CheckboxForm } from './widgets/GitResetToRemoteForm'; -import { AdvancedPushForm } from './widgets/AdvancedPushForm'; -import { PreviewMainAreaWidget } from './components/diff/PreviewMainAreaWidget'; -import { InputDialog } from '@jupyterlab/apputils'; export interface IGitCloneArgs { /** @@ -729,7 +729,8 @@ export function addCommands( const waitForDialog = new PromiseDelegate(); const dialog = ReactWidget.create( - { @@ -781,6 +782,167 @@ export function addCommands( ) }); + commands.addCommand(CommandIDs.gitRebase, { + label: trans.__('Rebase branch…'), + caption: trans.__('Rebase current branch onto the selected branch'), + execute: async args => { + let { branch }: { branch?: string } = args ?? {}; + + if (!branch) { + // Prompts user to pick a branch + const localBranches = gitModel.branches.filter( + branch => !branch.is_current_branch && !branch.is_remote_branch + ); + + const widgetId = 'git-dialog-MergeBranch'; + let anchor = document.querySelector(`#${widgetId}`); + if (!anchor) { + anchor = document.createElement('div'); + anchor.id = widgetId; + document.body.appendChild(anchor); + } + + const waitForDialog = new PromiseDelegate(); + const dialog = ReactWidget.create( + { + dialog.dispose(); + waitForDialog.resolve(branch ?? null); + }} + trans={trans} + /> + ); + + Widget.attach(dialog, anchor); + + branch = await waitForDialog.promise; + } + + if (branch) { + logger.log({ + level: Level.RUNNING, + message: trans.__("Rebasing current branch onto '%1'…", branch) + }); + try { + await gitModel.rebase(branch); + } catch (err) { + logger.log({ + level: Level.ERROR, + message: trans.__( + "Failed to rebase branch '%1' onto '%2'.", + gitModel.currentBranch.name, + branch + ), + error: err as Error + }); + return; + } + + logger.log({ + level: Level.SUCCESS, + message: trans.__( + "Branch '%1' rebase onto '%2'.", + gitModel.currentBranch.name, + branch + ) + }); + } + }, + isEnabled: () => + gitModel.branches.some( + branch => !branch.is_current_branch && !branch.is_remote_branch + ) + }); + + commands.addCommand(CommandIDs.gitResolveRebase, { + label: (args = {}) => { + switch (args.action) { + case 'continue': + return trans.__('Continue rebase'); + case 'skip': + return trans.__('Skip current commit'); + case 'abort': + return trans.__('Abort rebase'); + default: + return trans.__('Resolve rebase'); + } + }, + caption: (args = {}) => { + switch (args.action) { + case 'continue': + return trans.__( + 'Continue the rebase by committing the current state.' + ); + case 'skip': + return trans.__('Skip current commit and continue the rebase.'); + case 'abort': + return trans.__('Abort the rebase'); + default: + return trans.__('Resolve rebase'); + } + }, + execute: async (args: { action?: string } = {}) => { + const { action } = args; + + if (['continue', 'abort', 'skip'].includes(action)) { + const message = (action => { + switch (action) { + case 'continue': + return trans.__('Continue the rebase…'); + case 'skip': + return trans.__('Skip current commit…'); + case 'abort': + return trans.__('Abort the rebase…'); + } + })(action); + + logger.log({ + level: Level.RUNNING, + message + }); + try { + await gitModel.resolveRebase(action as any); + } catch (err) { + const message = (action => { + switch (action) { + case 'continue': + return trans.__('Fail to continue rebasing.'); + case 'skip': + return trans.__('Fail to skip current commit when rebasing.'); + case 'abort': + return trans.__('Fail to abort the rebase.'); + } + })(action); + logger.log({ + level: Level.ERROR, + message, + error: err as Error + }); + return; + } + + const message_ = (action => { + switch (action) { + case 'continue': + return trans.__('Commit submitted continuing rebase.'); + case 'skip': + return trans.__('Current commit skipped.'); + case 'abort': + return trans.__('Rebase aborted.'); + } + })(action); + logger.log({ + level: Level.SUCCESS, + message: message_ + }); + } + }, + isEnabled: () => gitModel.status.state === Git.State.REBASING + }); + commands.addCommand(CommandIDs.gitStash, { label: trans.__('Stash Changes'), caption: trans.__('Stash all current changes'), @@ -951,22 +1113,27 @@ export function addCommands( const repositoryPath = gitModel.pathRepository; const filename = filePath; const fullPath = PathExt.join(repositoryPath, filename); - const specialRef = - status === 'staged' - ? Git.Diff.SpecialRef.INDEX - : Git.Diff.SpecialRef.WORKING; - - const diffContext: Git.Diff.IContext = - status === 'unmerged' - ? { - currentRef: 'MERGE_HEAD', - previousRef: 'HEAD', - baseRef: Git.Diff.SpecialRef.BASE - } - : context ?? { - currentRef: specialRef, - previousRef: 'HEAD' - }; + + const diffContext: Git.Diff.IContext = { + currentRef: '', + previousRef: 'HEAD', + ...context + }; + + if (status === 'unmerged') { + diffContext.baseRef = Git.Diff.SpecialRef.BASE; + diffContext.currentRef = + gitModel.status.state !== Git.State.MERGING + ? gitModel.status.state === Git.State.REBASING + ? 'REBASE_HEAD' + : 'CHERRY_PICK_HEAD' + : 'MERGE_HEAD'; + } else if (!diffContext.currentRef) { + diffContext.currentRef = + status === 'staged' + ? Git.Diff.SpecialRef.INDEX + : Git.Diff.SpecialRef.WORKING; + } const challengerRef = Git.Diff.SpecialRef[diffContext.currentRef as any] ? { special: Git.Diff.SpecialRef[diffContext.currentRef as any] } diff --git a/src/components/BranchMenu.tsx b/src/components/BranchMenu.tsx index d2cffe76d..a40df0476 100644 --- a/src/components/BranchMenu.tsx +++ b/src/components/BranchMenu.tsx @@ -230,9 +230,16 @@ export class BranchMenu extends React.Component< private _renderBranchList(): React.ReactElement { // Perform a "simple" filter... (TODO: consider implementing fuzzy filtering) const filter = this.state.filter; - const branches = this.props.branches.filter( - branch => !filter || branch.name.includes(filter) - ); + const branches = this.props.branches.filter(branch => { + // Don't include "current branch" is the repository is not in default state + if ( + this.props.model.status.state !== Git.State.DEFAULT && + branch.is_current_branch + ) { + return false; + } + return !filter || branch.name.includes(filter); + }); return ( { const { data, index, style } = props; const branch = data[index] as Git.IBranch; - const isActive = branch.name === this.props.currentBranch; + const isActive = branch.is_current_branch; return ( (''); const [selectedBranch, setSelectedBranch] = React.useState( null @@ -74,7 +79,8 @@ export function MergeBranchDialog(props: IMergeBranchDialogProps): JSX.Element { branch => !filter || branch.name.includes(filter) ); - const { trans } = props; + const { action, trans } = props; + const act = action ?? 'merge'; function renderItem(props: ListChildComponentProps): JSX.Element { const { data, index, style } = props; @@ -84,7 +90,11 @@ export function MergeBranchDialog(props: IMergeBranchDialogProps): JSX.Element { return (
-

{trans.__('Merge Branch')}

+

+ {act === 'merge' + ? trans.__('Merge Branch') + : trans.__('Rebase Branch')} +

+ + + + {({ TransitionProps }) => ( + + + + + + {props.trans.__('Skip')} + + + {props.trans.__('Abort')} + + + + + + )} + +
+ ); +} diff --git a/src/components/StatusWidget.tsx b/src/components/StatusWidget.tsx index 5fac83d6e..b51bd2530 100644 --- a/src/components/StatusWidget.tsx +++ b/src/components/StatusWidget.tsx @@ -202,6 +202,12 @@ namespace Private { case 'git:pushing': status = 'pushing changes…'; break; + case 'git:rebase': + status = 'rebasing…'; + break; + case 'git:rebase:resolve': + status = 'resolving rebase…'; + break; case 'git:refresh': status = 'refreshing…'; break; diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index ed4a52c11..31ee272ce 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -260,12 +260,25 @@ export class Toolbar extends React.Component { * @returns React element */ private _renderBranchMenu(): React.ReactElement | null { - let branchTitle = this.props.trans.__('Current Branch'); + let branchTitle = ''; if (this.props.model.pathRepository === null) { return null; } - if (this.props.model.currentBranch?.detached) { - branchTitle = this.props.trans.__('Detached Head at'); + switch (this.props.model.status.state) { + case Git.State.CHERRY_PICKING: + branchTitle = this.props.trans.__('Cherry picking in'); + break; + case Git.State.DETACHED: + branchTitle = this.props.trans.__('Detached Head at'); + break; + case Git.State.MERGING: + branchTitle = this.props.trans.__('Merging in'); + break; + case Git.State.REBASING: + branchTitle = this.props.trans.__('Rebasing'); + break; + default: + branchTitle = this.props.trans.__('Current Branch'); } return ( diff --git a/src/index.ts b/src/index.ts index 31c88896d..a77e2be34 100644 --- a/src/index.ts +++ b/src/index.ts @@ -197,9 +197,9 @@ async function activate( CommandIDs.gitToggleSimpleStaging, CommandIDs.gitToggleDoubleClickDiff, CommandIDs.gitOpenGitignore, - CommandIDs.gitShowDiff, CommandIDs.gitInit, CommandIDs.gitMerge, + CommandIDs.gitRebase, CommandIDs.gitPush, CommandIDs.gitPull, CommandIDs.gitResetToRemote, diff --git a/src/model.ts b/src/model.ts index da7f094da..84e9144e5 100644 --- a/src/model.ts +++ b/src/model.ts @@ -1020,6 +1020,57 @@ export class GitExtension implements IGitExtension { return data; } + /** + * Rebase the current branch onto the provided one. + * + * @param branch to rebase onto + * @returns promise which resolves upon rebase action + * + * @throws {Git.NotInRepository} If the current path is not a Git repository + * @throws {Git.GitResponseError} If the server response is not ok + * @throws {ServerConnection.NetworkError} If the request cannot be made + */ + async rebase(branch: string): Promise { + const path = await this._getPathRepository(); + return this._taskHandler.execute( + 'git:rebase', + () => { + return requestAPI( + URLExt.join(path, 'rebase'), + 'POST', + { + branch + } + ); + } + ); + } + + /** + * Resolve in progress rebase. + * + * @param action to perform + * @returns promise which resolves upon rebase action + * + * @throws {Git.NotInRepository} If the current path is not a Git repository + * @throws {Git.GitResponseError} If the server response is not ok + * @throws {ServerConnection.NetworkError} If the request cannot be made + */ + async resolveRebase( + action: 'continue' | 'skip' | 'abort' + ): Promise { + const path = await this._getPathRepository(); + return this._taskHandler.execute( + 'git:rebase:resolve', + () => + requestAPI( + URLExt.join(path, 'rebase'), + 'POST', + { action } + ) + ); + } + /** * Refresh the repository. * @@ -1062,14 +1113,6 @@ export class GitExtension implements IGitExtension { this._branches = data.branches ?? []; - const detachedHeadRegex = /\(HEAD detached at (.+)\)/; - const result = data.current_branch.name.match(detachedHeadRegex); - - if (result && result.length > 1) { - data.current_branch.name = result[1]; - data.current_branch.detached = true; - } - this._currentBranch = data.current_branch; if (this._currentBranch) { // Set up the marker obj for the current (valid) repo/branch combination @@ -1149,6 +1192,7 @@ export class GitExtension implements IGitExtension { remote: data.remote || null, ahead: data.ahead || 0, behind: data.behind || 0, + state: data.state ?? 0, files }); await this.refreshDirtyStatus(); @@ -1868,6 +1912,7 @@ export class GitExtension implements IGitExtension { remote: null, ahead: 0, behind: 0, + state: Git.State.DEFAULT, files: [] }; } @@ -1912,6 +1957,7 @@ export class GitExtension implements IGitExtension { this._status.ahead === v.ahead && this._status.behind === v.behind && this._status.branch === v.branch && + this._status.state === v.state && this._status.files.length === v.files.length; if (areEqual) { for (const file of v.files) { diff --git a/src/style/FileListStyle.ts b/src/style/FileListStyle.ts index 6ed2e1c11..98524d482 100644 --- a/src/style/FileListStyle.ts +++ b/src/style/FileListStyle.ts @@ -1,6 +1,7 @@ import { style } from 'typestyle'; export const fileListWrapperClass = style({ + flex: '1 1 auto', minHeight: '150px', overflow: 'hidden', diff --git a/src/style/GitStashStyle.ts b/src/style/GitStashStyle.ts index edcc94463..d5fe5433a 100644 --- a/src/style/GitStashStyle.ts +++ b/src/style/GitStashStyle.ts @@ -1,4 +1,17 @@ import { style } from 'typestyle'; +import type { NestedCSSProperties } from 'typestyle/lib/types'; +import { sectionAreaStyle } from './GitStageStyle'; + +export const stashContainerStyle = style( + (() => { + const styled: NestedCSSProperties = { $nest: {} }; + + styled.$nest[`& > .${sectionAreaStyle}`] = { + margin: 0 + }; + return styled; + })() +); export const sectionHeaderLabelStyle = style({ fontSize: 'var(--jp-ui-font-size1)', diff --git a/src/style/RebaseActionStyle.ts b/src/style/RebaseActionStyle.ts new file mode 100644 index 000000000..26a2519c8 --- /dev/null +++ b/src/style/RebaseActionStyle.ts @@ -0,0 +1,5 @@ +import { style } from 'typestyle'; + +export const rebaseActionStyle = style({ + padding: '8px' +}); diff --git a/src/tokens.ts b/src/tokens.ts index 102e2114e..1a6281aee 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -476,6 +476,32 @@ export interface IGitExtension extends IDisposable { remote?: string ): Promise; + /** + * Rebase the current branch onto the provided one. + * + * @param branch to rebase onto + * @returns promise which resolves upon rebase action + * + * @throws {Git.NotInRepository} If the current path is not a Git repository + * @throws {Git.GitResponseError} If the server response is not ok + * @throws {ServerConnection.NetworkError} If the request cannot be made + */ + rebase(branch: string): Promise; + + /** + * Resolve in progress rebase. + * + * @param action to perform + * @returns promise which resolves upon rebase action + * + * @throws {Git.NotInRepository} If the current path is not a Git repository + * @throws {Git.GitResponseError} If the server response is not ok + * @throws {ServerConnection.NetworkError} If the request cannot be made + */ + resolveRebase( + action: 'continue' | 'skip' | 'abort' + ): Promise; + /** * General Git refresh */ @@ -755,14 +781,40 @@ export namespace Git { export enum SpecialRef { // Working version - 'WORKING', + WORKING, // Index version - 'INDEX', + INDEX, // Common ancestor version (useful for unmerged files) - 'BASE' + BASE } } + /** + * Git repository state + */ + export enum State { + /** + * Default state + */ + DEFAULT = 0, + /** + * Detached head state + */ + DETACHED, + /** + * Merge in progress + */ + MERGING, + /** + * Rebase in progress + */ + REBASING, + /** + * Cherry-pick in progress + */ + CHERRY_PICKING + } + /** * Interface for GitAllHistory request result, * has all repo information @@ -870,7 +922,6 @@ export namespace Git { upstream: string | null; top_commit: string; tag: string | null; - detached?: boolean; } /** Interface for GitBranch request result, @@ -927,6 +978,10 @@ export namespace Git { * Number of commits behind */ behind: number; + /** + * Git repository state + */ + state: Git.State; /** * Files status */ @@ -962,6 +1017,7 @@ export namespace Git { remote?: string | null; ahead?: number; behind?: number; + state?: number; files?: IStatusFileResult[]; } @@ -1301,6 +1357,8 @@ export enum CommandIDs { gitOpenGitignore = 'git:open-gitignore', gitPush = 'git:push', gitPull = 'git:pull', + gitRebase = 'git:rebase', + gitResolveRebase = 'git:resolve-rebase', gitResetToRemote = 'git:reset-to-remote', gitSubmitCommand = 'git:submit-commit', gitShowDiff = 'git:show-diff', diff --git a/ui-tests/tests/merge-conflict.spec.ts b/ui-tests/tests/merge-conflict.spec.ts index 800ad5578..4a2b9f6a0 100644 --- a/ui-tests/tests/merge-conflict.spec.ts +++ b/ui-tests/tests/merge-conflict.spec.ts @@ -18,25 +18,32 @@ test.describe('Merge conflict tests', () => { await page.sidebar.openTab('jp-git-sessions'); - await page.locator('button:has-text("Current Branchmaster")').click(); + await page.getByRole('button', { name: 'Current Branch master' }).click(); // Click on a-branch merge button await page.locator('text=a-branch').hover(); - await page.locator('text=a-branchmaster >> button').nth(1).click(); + await page + .getByRole('button', { + name: 'Merge this branch into the current one', + exact: true + }) + .click(); // Hide branch panel - await page.locator('button:has-text("Current Branchmaster")').click(); + await page.getByRole('button', { name: 'Current Branch master' }).click(); // Force refresh await page - .locator( - 'button[title="Refresh the repository to detect local and remote changes"]' - ) + .getByRole('button', { + name: 'Refresh the repository to detect local and remote changes' + }) .click(); }); test('should diff conflicted text file', async ({ page }) => { - await page.click('[title="file.txt • Conflicted"]', { clickCount: 2 }); + await page + .getByTitle('file.txt • Conflicted', { exact: true }) + .click({ clickCount: 2 }); await page.waitForSelector( '.jp-git-diff-parent-widget[id^="Current-Incoming"] .jp-spinner', { state: 'detached' } @@ -54,7 +61,7 @@ test.describe('Merge conflict tests', () => { }); test('should diff conflicted notebook file', async ({ page }) => { - await page.click('[title="example.ipynb • Conflicted"]', { + await page.getByTitle('example.ipynb • Conflicted').click({ clickCount: 2 }); await page.waitForSelector( diff --git a/ui-tests/tests/rebase.spec.ts b/ui-tests/tests/rebase.spec.ts new file mode 100644 index 000000000..af22c08c7 --- /dev/null +++ b/ui-tests/tests/rebase.spec.ts @@ -0,0 +1,118 @@ +import { expect, galata, test } from '@jupyterlab/galata'; +import path from 'path'; +import { extractFile } from './utils'; + +const baseRepositoryPath = 'test-repository.tar.gz'; +test.use({ autoGoto: false, mockSettings: galata.DEFAULT_SETTINGS }); + +test.describe('Rebase', () => { + test.beforeEach(async ({ baseURL, page, tmpPath }) => { + await extractFile( + baseURL, + path.resolve(__dirname, 'data', baseRepositoryPath), + path.join(tmpPath, 'repository.tar.gz') + ); + + // URL for merge conflict example repository + await page.goto(`tree/${tmpPath}/test-repository`); + + await page.sidebar.openTab('jp-git-sessions'); + + await page.getByRole('button', { name: 'Current Branch master' }).click(); + + // Switch to a-branch + await page.getByRole('button', { name: 'a-branch' }).click(); + + // Hide branch panel + await page.getByRole('button', { name: 'Current Branch a-branch' }).click(); + + // Rebase on master + await page.getByRole('main').press('Control+Shift+C'); + await page.getByRole('textbox', { name: 'SEARCH' }).fill('rebase'); + await page.getByText('Rebase branch…').click(); + await page.getByRole('button', { name: 'master' }).click(); + await page.getByRole('button', { name: 'Rebase' }).click(); + + // Force refresh + await page + .getByRole('button', { + name: 'Refresh the repository to detect local and remote changes' + }) + .click(); + }); + + test('should resolve a conflicted rebase', async ({ page }) => { + // Resolve conflicts + await page.getByTitle('file.txt • Conflicted', { exact: true }).dblclick(); + await page.waitForSelector( + '.jp-git-diff-parent-widget[id^="Current-Incoming"] .jp-spinner', + { state: 'detached' } + ); + await page.waitForSelector('.jp-git-diff-root'); + + // Verify 3-way merge view appears + const banner = page.locator('.jp-git-merge-banner'); + await expect.soft(banner).toHaveText(/Current/); + await expect.soft(banner).toHaveText(/Result/); + await expect.soft(banner).toHaveText(/Incoming/); + + await page.getByRole('button', { name: 'Mark as resolved' }).click(); + + await page + .getByTitle('another_file.txt • Conflicted', { exact: true }) + .dblclick(); + + await page.getByRole('button', { name: 'Mark as resolved' }).click(); + + await page.getByTitle('example.ipynb • Conflicted').click({ + clickCount: 2 + }); + await page.waitForSelector( + '.jp-git-diff-parent-widget[id^="Current-Incoming"] .jp-spinner', + { state: 'detached' } + ); + await page.waitForSelector('.jp-git-diff-root'); + + // Verify notebook merge view appears + await expect.soft(banner).toHaveText(/Current/); + await expect.soft(banner).toHaveText(/Incoming/); + + await page.getByRole('button', { name: 'Mark as resolved' }).click(); + + // Continue rebase as all conflicts are resolved + await page.getByRole('button', { name: 'Continue' }).click(); + + await page.getByRole('tab', { name: 'History' }).click(); + + // Master changes must be part of the history following the rebase + await expect.soft(page.getByTitle('View commit details')).toHaveCount(3); + await expect(page.getByText('master changes')).toBeVisible(); + }); + + test('should abort a rebase', async ({ page }) => { + await page.getByTitle('Pick another rebase action.').click(); + + await page.getByRole('menuitem', { name: 'Abort' }).click(); + + await page.getByRole('button', { name: 'Abort' }).click(); + + await page.getByRole('tab', { name: 'History' }).click(); + + // Master changes must not be part of the history following the abort + await expect.soft(page.getByTitle('View commit details')).toHaveCount(2); + await expect(page.getByText('a-branch changes')).toBeVisible(); + }); + + test('should skip the current commit', async ({ page }) => { + await page.getByTitle('Pick another rebase action.').click(); + + await page.getByRole('menuitem', { name: 'Skip' }).click(); + + await page.getByRole('tab', { name: 'History' }).click(); + + // Master changes must be part of the history following the rebase but not + // the old a-branch commit. + await expect(page.getByTitle('View commit details')).toHaveCount(2); + await expect(page.getByText('master changes')).toBeVisible(); + }); +});