diff --git a/jupyterlab_git/__init__.py b/jupyterlab_git/__init__.py index 81ae25033..69c04979f 100644 --- a/jupyterlab_git/__init__.py +++ b/jupyterlab_git/__init__.py @@ -4,7 +4,7 @@ import json from pathlib import Path -from traitlets import List, Dict, Unicode +from traitlets import List, Dict, Unicode, default from traitlets.config import Configurable from ._version import __version__ @@ -37,6 +37,18 @@ class JupyterLabGit(Configurable): # TODO Validate ) + credential_helper = Unicode( + help=""" + The value of Git credential helper will be set to this value when the Git credential caching mechanism is activated by this extension. + By default it is an in-memory cache of 3600 seconds (1 hour); `cache --timeout=3600`. + """, + config=True, + ) + + @default("credential_helper") + def _credential_helper_default(self): + return "cache --timeout=3600" + def _jupyter_server_extension_points(): return [{"module": "jupyterlab_git"}] diff --git a/jupyterlab_git/git.py b/jupyterlab_git/git.py index f67c4bcf1..9975a009e 100644 --- a/jupyterlab_git/git.py +++ b/jupyterlab_git/git.py @@ -8,6 +8,8 @@ import shlex import subprocess import traceback +from typing import Dict, List, Optional +from unittest.mock import NonCallableMock from urllib.parse import unquote import nbformat @@ -37,6 +39,8 @@ GIT_BRANCH_STATUS = re.compile( r"^## (?P([\w\-/]+|HEAD \(no branch\)|No commits yet on \w+))(\.\.\.(?P[\w\-/]+)( \[(ahead (?P\d+))?(, )?(behind (?P\d+))?\])?)?$" ) +# Git cache as a credential helper +GIT_CREDENTIAL_HELPER_CACHE = re.compile(r"cache\b") execution_lock = tornado.locks.Lock() @@ -174,9 +178,15 @@ class Git: A single parent class containing all of the individual git methods in it. """ + _GIT_CREDENTIAL_CACHE_DAEMON_PROCESS: subprocess.Popen = None + def __init__(self, config=None): self._config = config + def __del__(self): + if self._GIT_CREDENTIAL_CACHE_DAEMON_PROCESS: + self._GIT_CREDENTIAL_CACHE_DAEMON_PROCESS.terminate() + async def config(self, path, **kwargs): """Get or set Git options. @@ -273,6 +283,8 @@ async def clone(self, path, repo_url, auth=None): """ env = os.environ.copy() if auth: + if auth.get("cache_credentials"): + await self.ensure_credential_helper(path) env["GIT_TERMINAL_PROMPT"] = "1" code, output, error = await execute( ["git", "clone", unquote(repo_url), "-q"], @@ -296,7 +308,7 @@ async def clone(self, path, repo_url, auth=None): return response - async def fetch(self, path): + async def fetch(self, path, auth=None): """ Execute git fetch command """ @@ -308,8 +320,21 @@ async def fetch(self, path): "--all", "--prune", ] # Run prune by default to help beginners - - code, _, fetch_error = await execute(cmd, cwd=cwd) + env = os.environ.copy() + if auth: + if auth.get("cache_credentials"): + await self.ensure_credential_helper(path) + env["GIT_TERMINAL_PROMPT"] = "1" + code, _, fetch_error = await execute( + cmd, + cwd=cwd, + username=auth["username"], + password=auth["password"], + env=env, + ) + else: + env["GIT_TERMINAL_PROMPT"] = "0" + code, _, fetch_error = await execute(cmd, cwd=cwd, env=env) result = { "code": code, @@ -317,6 +342,7 @@ async def fetch(self, path): if code != 0: result["command"] = " ".join(cmd) result["error"] = fetch_error + result["message"] = fetch_error return result @@ -1005,6 +1031,8 @@ async def pull(self, path, auth=None, cancel_on_conflict=False): """ env = os.environ.copy() if auth: + if auth.get("cache_credentials"): + await self.ensure_credential_helper(path) env["GIT_TERMINAL_PROMPT"] = "1" code, output, error = await execute( ["git", "pull", "--no-commit"], @@ -1048,7 +1076,13 @@ async def pull(self, path, auth=None, cancel_on_conflict=False): return response async def push( - self, remote, branch, path, auth=None, set_upstream=False, force=False + self, + remote, + branch, + path, + auth=None, + set_upstream=False, + force=False, ): """ Execute `git push $UPSTREAM $BRANCH`. The choice of upstream and branch is up to the caller. @@ -1062,6 +1096,8 @@ async def push( env = os.environ.copy() if auth: + if auth.get("cache_credentials"): + await self.ensure_credential_helper(path) env["GIT_TERMINAL_PROMPT"] = "1" code, output, error = await execute( command, @@ -1543,3 +1579,127 @@ async def tag_checkout(self, path, tag): "command": " ".join(command), "message": error, } + + async def check_credential_helper(self, path: str) -> Optional[bool]: + """ + Check if the credential helper exists, and whether we need to setup a Git credential cache daemon in case the credential helper is Git credential cache. + + path: str + Git path repository + + Return None if the credential helper is not set. + Otherwise, return True if we need to setup a Git credential cache daemon, else False. + + Raise an exception if `git config` errored. + """ + + git_config_response: Dict[str, str] = await self.config(path) + if git_config_response["code"] != 0: + raise RuntimeError(git_config_response["message"]) + + git_config_kv_pairs = git_config_response["options"] + has_credential_helper = "credential.helper" in git_config_kv_pairs + + if not has_credential_helper: + return None + + if has_credential_helper and GIT_CREDENTIAL_HELPER_CACHE.match( + git_config_kv_pairs["credential.helper"].strip() + ): + return True + + return False + + async def ensure_credential_helper( + self, path: str, env: Dict[str, str] = None + ) -> None: + """ + Check whether `git config --list` contains `credential.helper`. + If it is not set, then it will be set to the value string for `credential.helper` + defined in the server settings. + + path: str + Git path repository + env: Dict[str, str] + Environment variables + """ + + try: + has_credential_helper = await self.check_credential_helper(path) + if has_credential_helper == False: + return + except RuntimeError as e: + get_logger().error("Error checking credential helper: %s", e, exc_info=True) + return + + cache_daemon_required = has_credential_helper == True + + if has_credential_helper is None: + credential_helper: str = self._config.credential_helper + await self.config(path, **{"credential.helper": credential_helper}) + if GIT_CREDENTIAL_HELPER_CACHE.match(credential_helper.strip()): + cache_daemon_required = True + + # special case: Git credential cache + if cache_daemon_required: + try: + self.ensure_git_credential_cache_daemon(cwd=path, env=env) + except Exception as e: + get_logger().error( + "Error setting up Git credential cache daemon: %s", e, exc_info=True + ) + + def ensure_git_credential_cache_daemon( + self, + socket: Optional[pathlib.Path] = None, + debug: bool = False, + force: bool = False, + cwd: Optional[str] = None, + env: Dict[str, str] = None, + ) -> None: + """ + Spawn a Git credential cache daemon with the socket file being `socket` if it does not exist. + If `debug` is `True`, the daemon will be spawned with `--debug` flag. + If `socket` is empty, it is set to `~/.git-credential-cache-daemon`. + If `force` is `True`, a daemon will be spawned, and if the daemon process is accessible, + the existing daemon process will be terminated before spawning a new one. + Otherwise, if `force` is `False`, the PID of the existing daemon process is returned. + If the daemon process is not accessible, `-1` is returned. + `cwd` and `env` are passed to the process that spawns the daemon. + """ + + if not socket: + socket = pathlib.Path.home() / ".git-credential-cache" / "socket" + + if socket.exists(): + return + + if self._GIT_CREDENTIAL_CACHE_DAEMON_PROCESS is None or force: + + if force and self._GIT_CREDENTIAL_CACHE_DAEMON_PROCESS: + self._GIT_CREDENTIAL_CACHE_DAEMON_PROCESS.terminate() + + if not socket.parent.exists(): + socket.parent.mkdir(parents=True, exist_ok=True) + socket.parent.chmod(0o700) + + args: List[str] = ["git", "credential-cache--daemon"] + + if debug: + args.append("--debug") + + args.append(socket) + + self._GIT_CREDENTIAL_CACHE_DAEMON_PROCESS = subprocess.Popen( + args, + cwd=cwd, + env=env, + ) + + get_logger().debug( + "A credential cache daemon has been spawned with PID %d", + self._GIT_CREDENTIAL_CACHE_DAEMON_PROCESS.pid, + ) + + elif self._GIT_CREDENTIAL_CACHE_DAEMON_PROCESS.poll(): + self.ensure_git_credential_cache_daemon(socket, debug, True, cwd, env) diff --git a/jupyterlab_git/handlers.py b/jupyterlab_git/handlers.py index 14784043c..7b16ef009 100644 --- a/jupyterlab_git/handlers.py +++ b/jupyterlab_git/handlers.py @@ -67,13 +67,16 @@ async def post(self, path: str = ""): { 'repo_url': 'https://github.com/path/to/myrepo', OPTIONAL 'auth': '{ 'username': '', - 'password': '' + 'password': '', + 'cache_credentials': true/false }' } """ data = self.get_json_body() response = await self.git.clone( - self.url2localpath(path), data["clone_url"], data.get("auth", None) + self.url2localpath(path), + data["clone_url"], + data.get("auth", None), ) if response["code"] != 0: @@ -170,7 +173,11 @@ async def post(self, path: str = ""): """ POST request handler, fetch from remotes. """ - result = await self.git.fetch(self.url2localpath(path)) + data = self.get_json_body() + result = await self.git.fetch( + self.url2localpath(path), + data.get("auth", None), + ) if result["code"] != 0: self.set_status(500) diff --git a/jupyterlab_git/tests/test_clone.py b/jupyterlab_git/tests/test_clone.py index 00d369678..3b2c0ddd6 100644 --- a/jupyterlab_git/tests/test_clone.py +++ b/jupyterlab_git/tests/test_clone.py @@ -1,8 +1,10 @@ +import os from pathlib import Path -from unittest.mock import patch +from unittest.mock import call, patch import pytest +from jupyterlab_git import JupyterLabGit from jupyterlab_git.git import Git from .testutils import maybe_future @@ -156,3 +158,93 @@ async def test_git_clone_with_auth_auth_failure_from_git(): "code": 128, "message": "remote: Invalid username or password.\r\nfatal: Authentication failed for 'ghjkhjkl'", } == actual_response + + +@pytest.mark.asyncio +async def test_git_clone_with_auth_and_cache_credentials(): + with patch( + "jupyterlab_git.git.Git.ensure_git_credential_cache_daemon" + ) as mock_ensure_daemon: + mock_ensure_daemon.return_value = 0 + with patch("jupyterlab_git.git.execute") as mock_execute: + # Given + default_config = JupyterLabGit() + default_config.credential_helper = "cache" + credential_helper = default_config.credential_helper + test_path = "test_curr_path" + mock_execute.side_effect = [ + maybe_future((0, "", "")), + maybe_future((0, "", "")), + maybe_future((0, "", "")), + ] + # When + auth = { + "username": "asdf", + "password": "qwerty", + "cache_credentials": True, + } + actual_response = await Git(config=default_config).clone( + path=test_path, + repo_url="ghjkhjkl", + auth=auth, + ) + + # Then + assert mock_execute.call_count == 3 + mock_execute.assert_has_calls( + [ + call(["git", "config", "--list"], cwd=test_path), + call( + [ + "git", + "config", + "--add", + "credential.helper", + credential_helper, + ], + cwd=test_path, + ), + call( + ["git", "clone", "ghjkhjkl", "-q"], + username="asdf", + password="qwerty", + cwd=test_path, + env={**os.environ, "GIT_TERMINAL_PROMPT": "1"}, + ), + ] + ) + mock_ensure_daemon.assert_called_once_with(cwd=test_path, env=None) + assert {"code": 0, "message": ""} == actual_response + + +@pytest.mark.asyncio +async def test_git_clone_with_auth_and_cache_credentials_and_existing_credential_helper(): + with patch("jupyterlab_git.git.execute") as mock_execute: + # Given + default_config = JupyterLabGit() + test_path = str(Path("/bin") / "test_curr_path") + mock_execute.side_effect = [ + maybe_future((0, "credential.helper=something", "")), + maybe_future((0, "", "")), + ] + # When + auth = {"username": "asdf", "password": "qwerty", "cache_credentials": True} + actual_response = await Git(config=default_config).clone( + path=test_path, repo_url="ghjkhjkl", auth=auth + ) + + # Then + assert mock_execute.call_count == 2 + mock_execute.assert_has_calls( + [ + call(["git", "config", "--list"], cwd=test_path), + call( + ["git", "clone", "ghjkhjkl", "-q"], + username="asdf", + password="qwerty", + cwd=test_path, + env={**os.environ, "GIT_TERMINAL_PROMPT": "1"}, + ), + ] + ) + assert {"code": 0, "message": ""} == actual_response diff --git a/jupyterlab_git/tests/test_fetch.py b/jupyterlab_git/tests/test_fetch.py new file mode 100644 index 000000000..5265d610f --- /dev/null +++ b/jupyterlab_git/tests/test_fetch.py @@ -0,0 +1,197 @@ +import os +from unittest.mock import call, patch + +import pytest + +from jupyterlab_git import JupyterLabGit +from jupyterlab_git.git import Git + +from .testutils import maybe_future + + +@pytest.mark.asyncio +async def test_git_fetch_success(): + with patch("jupyterlab_git.git.execute") as mock_execute: + # Given + mock_execute.return_value = maybe_future((0, "", "")) + + # When + actual_response = await Git().fetch(path="test_path") + + # Then + mock_execute.assert_called_once_with( + ["git", "fetch", "--all", "--prune"], + cwd="test_path", + env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, + ) + assert {"code": 0} == actual_response + + +@pytest.mark.asyncio +async def test_git_fetch_fail(): + with patch("jupyterlab_git.git.execute") as mock_execute: + # Given + mock_execute.return_value = maybe_future((1, "", "error")) + + # When + actual_response = await Git().fetch(path="test_path") + + # Then + mock_execute.assert_called_once_with( + ["git", "fetch", "--all", "--prune"], + cwd="test_path", + env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, + ) + assert { + "code": 1, + "command": "git fetch --all --prune", + "error": "error", + "message": "error", + } == actual_response + + +@pytest.mark.asyncio +async def test_git_fetch_with_auth_success(): + with patch("jupyterlab_git.git.execute") as mock_execute: + # Given + mock_execute.return_value = maybe_future((0, "", "")) + + # When + actual_response = await Git().fetch( + path="test_path", auth={"username": "test_user", "password": "test_pass"} + ) + + # Then + mock_execute.assert_called_once_with( + ["git", "fetch", "--all", "--prune"], + username="test_user", + password="test_pass", + cwd="test_path", + env={**os.environ, "GIT_TERMINAL_PROMPT": "1"}, + ) + assert {"code": 0} == actual_response + + +@pytest.mark.asyncio +async def test_git_fetch_with_auth_fail(): + with patch("jupyterlab_git.git.execute") as mock_execute: + # Given + error_message = "remote: Invalid username or password.\r\nfatal: Authentication failed for 'test_repo'" + mock_execute.return_value = maybe_future( + ( + 128, + "", + error_message, + ) + ) + + # When + actual_response = await Git().fetch( + path="test_path", auth={"username": "test_user", "password": "test_pass"} + ) + + # Then + mock_execute.assert_called_once_with( + ["git", "fetch", "--all", "--prune"], + username="test_user", + password="test_pass", + cwd="test_path", + env={**os.environ, "GIT_TERMINAL_PROMPT": "1"}, + ) + assert { + "code": 128, + "command": "git fetch --all --prune", + "error": error_message, + "message": error_message, + } == actual_response + + +@pytest.mark.asyncio +async def test_git_fetch_with_auth_and_cache_credentials(): + with patch( + "jupyterlab_git.git.Git.ensure_git_credential_cache_daemon" + ) as mock_ensure_daemon: + mock_ensure_daemon.return_value = 0 + with patch("jupyterlab_git.git.execute") as mock_execute: + # Given + default_config = JupyterLabGit() + credential_helper = default_config.credential_helper + test_path = "test_path" + mock_execute.side_effect = [ + maybe_future((0, "", "")), + maybe_future((0, "", "")), + maybe_future((0, "", "")), + ] + # When + actual_response = await Git(config=default_config).fetch( + path=test_path, + auth={ + "username": "test_user", + "password": "test_pass", + "cache_credentials": True, + }, + ) + + # Then + assert mock_execute.call_count == 3 + mock_execute.assert_has_calls( + [ + call(["git", "config", "--list"], cwd=test_path), + call( + [ + "git", + "config", + "--add", + "credential.helper", + credential_helper, + ], + cwd=test_path, + ), + call( + ["git", "fetch", "--all", "--prune"], + username="test_user", + password="test_pass", + cwd=test_path, + env={**os.environ, "GIT_TERMINAL_PROMPT": "1"}, + ), + ] + ) + mock_ensure_daemon.assert_called_once_with(cwd=test_path, env=None) + assert {"code": 0} == actual_response + + +@pytest.mark.asyncio +async def test_git_fetch_with_auth_and_cache_credentials_and_existing_credential_helper(): + with patch("jupyterlab_git.git.execute") as mock_execute: + # Given + default_config = JupyterLabGit() + test_path = "test_path" + mock_execute.side_effect = [ + maybe_future((0, "credential.helper=something", "")), + maybe_future((0, "", "")), + ] + # When + actual_response = await Git(config=default_config).fetch( + path="test_path", + auth={ + "username": "test_user", + "password": "test_pass", + "cache_credentials": True, + }, + ) + + # Then + assert mock_execute.call_count == 2 + mock_execute.assert_has_calls( + [ + call(["git", "config", "--list"], cwd=test_path), + call( + ["git", "fetch", "--all", "--prune"], + username="test_user", + password="test_pass", + cwd=test_path, + env={**os.environ, "GIT_TERMINAL_PROMPT": "1"}, + ), + ] + ) + assert {"code": 0} == actual_response diff --git a/jupyterlab_git/tests/test_handlers.py b/jupyterlab_git/tests/test_handlers.py index 6cf34fce0..1b506dd7d 100644 --- a/jupyterlab_git/tests/test_handlers.py +++ b/jupyterlab_git/tests/test_handlers.py @@ -463,7 +463,12 @@ async def test_push_handler_noupstream_unique_remote(mock_git, jp_fetch, jp_root mock_git.config.assert_called_with(str(local_path)) mock_git.remote_show.assert_called_with(str(local_path)) mock_git.push.assert_called_with( - remote, "foo", str(local_path), None, set_upstream=True, force=False + remote, + "foo", + str(local_path), + None, + set_upstream=True, + force=False, ) assert response.code == 200 @@ -496,7 +501,12 @@ async def test_push_handler_noupstream_pushdefault(mock_git, jp_fetch, jp_root_d mock_git.config.assert_called_with(str(local_path)) mock_git.remote_show.assert_called_with(str(local_path)) mock_git.push.assert_called_with( - remote, "foo", str(local_path), None, set_upstream=True, force=False + remote, + "foo", + str(local_path), + None, + set_upstream=True, + force=False, ) assert response.code == 200 diff --git a/jupyterlab_git/tests/test_pushpull.py b/jupyterlab_git/tests/test_pushpull.py index 04cf9288e..8821373c9 100644 --- a/jupyterlab_git/tests/test_pushpull.py +++ b/jupyterlab_git/tests/test_pushpull.py @@ -1,7 +1,9 @@ +import os from unittest.mock import call, patch import pytest +from jupyterlab_git import JupyterLabGit from jupyterlab_git.git import Git from .testutils import maybe_future @@ -173,6 +175,93 @@ async def test_git_pull_with_auth_success_and_conflict_fail(): } == actual_response +@pytest.mark.asyncio +async def test_git_pull_with_auth_and_cache_credentials(): + with patch( + "jupyterlab_git.git.Git.ensure_git_credential_cache_daemon" + ) as mock_ensure_daemon: + mock_ensure_daemon.return_value = 0 + with patch("jupyterlab_git.git.execute") as mock_execute: + # Given + default_config = JupyterLabGit() + credential_helper = default_config.credential_helper + test_path = "test_path" + mock_execute.side_effect = [ + maybe_future((0, "", "")), + maybe_future((0, "", "")), + maybe_future((0, "", "")), + ] + + # When + auth = {"username": "user", "password": "pass", "cache_credentials": True} + actual_response = await Git(config=default_config).pull(test_path, auth) + + # Then + assert mock_execute.call_count == 3 + mock_execute.assert_has_calls( + [ + call( + ["git", "config", "--list"], + cwd=test_path, + ), + call( + [ + "git", + "config", + "--add", + "credential.helper", + credential_helper, + ], + cwd=test_path, + ), + call( + ["git", "pull", "--no-commit"], + username="user", + password="pass", + cwd=test_path, + env={**os.environ, "GIT_TERMINAL_PROMPT": "1"}, + ), + ] + ) + mock_ensure_daemon.assert_called_once_with(cwd=test_path, env=None) + assert {"code": 0, "message": ""} == actual_response + + +@pytest.mark.asyncio +async def test_git_pull_with_auth_and_cache_credentials_and_existing_credential_helper(): + with patch("jupyterlab_git.git.execute") as mock_execute: + # Given + default_config = JupyterLabGit() + test_path = "test_path" + mock_execute.side_effect = [ + maybe_future((0, "credential.helper=something", "")), + maybe_future((0, "", "")), + ] + + # When + auth = {"username": "user", "password": "pass", "cache_credentials": True} + actual_response = await Git(config=default_config).pull(test_path, auth) + + # Then + assert mock_execute.call_count == 2 + mock_execute.assert_has_calls( + [ + call( + ["git", "config", "--list"], + cwd=test_path, + ), + call( + ["git", "pull", "--no-commit"], + username="user", + password="pass", + cwd=test_path, + env={**os.environ, "GIT_TERMINAL_PROMPT": "1"}, + ), + ] + ) + assert {"code": 0, "message": ""} == actual_response + + @pytest.mark.asyncio async def test_git_push_fail(): with patch("os.environ", {"TEST": "test"}): @@ -279,3 +368,94 @@ async def test_git_push_with_auth_success(): env={"TEST": "test", "GIT_TERMINAL_PROMPT": "1"}, ) assert {"code": 0, "message": output} == actual_response + + +@pytest.mark.asyncio +async def test_git_push_with_auth_and_cache_credentials(): + with patch( + "jupyterlab_git.git.Git.ensure_git_credential_cache_daemon" + ) as mock_ensure_daemon: + mock_ensure_daemon.return_value = 0 + with patch("jupyterlab_git.git.execute") as mock_execute: + # Given + default_config = JupyterLabGit() + credential_helper = default_config.credential_helper + test_path = "test_path" + mock_execute.side_effect = [ + maybe_future((0, "", "")), + maybe_future((0, "", "")), + maybe_future((0, "", "")), + ] + + # When + auth = {"username": "user", "password": "pass", "cache_credentials": True} + actual_response = await Git(config=default_config).push( + ".", "HEAD:test_master", test_path, auth + ) + + # Then + assert mock_execute.call_count == 3 + mock_execute.assert_has_calls( + [ + call( + ["git", "config", "--list"], + cwd=test_path, + ), + call( + [ + "git", + "config", + "--add", + "credential.helper", + credential_helper, + ], + cwd=test_path, + ), + call( + ["git", "push", ".", "HEAD:test_master"], + username="user", + password="pass", + cwd=test_path, + env={**os.environ, "GIT_TERMINAL_PROMPT": "1"}, + ), + ] + ) + mock_ensure_daemon.assert_called_once_with(cwd=test_path, env=None) + assert {"code": 0, "message": ""} == actual_response + + +@pytest.mark.asyncio +async def test_git_push_with_auth_and_cache_credentials_and_existing_credential_helper(): + with patch("jupyterlab_git.git.execute") as mock_execute: + # Given + default_config = JupyterLabGit() + test_path = "test_path" + mock_execute.side_effect = [ + maybe_future((0, "credential.helper=something", "")), + maybe_future((0, "", "")), + ] + + # When + auth = {"username": "user", "password": "pass", "cache_credentials": True} + actual_response = await Git(config=default_config).push( + ".", "HEAD:test_master", test_path, auth + ) + + # Then + assert mock_execute.call_count == 2 + mock_execute.assert_has_calls( + [ + call( + ["git", "config", "--list"], + cwd=test_path, + ), + call( + ["git", "push", ".", "HEAD:test_master"], + username="user", + password="pass", + cwd=test_path, + env={**os.environ, "GIT_TERMINAL_PROMPT": "1"}, + ), + ] + ) + assert {"code": 0, "message": ""} == actual_response diff --git a/src/commandsAndMenu.tsx b/src/commandsAndMenu.tsx index 220d42527..bd0cd77dd 100644 --- a/src/commandsAndMenu.tsx +++ b/src/commandsAndMenu.tsx @@ -67,7 +67,8 @@ export enum Operation { Clone = 'Clone', Pull = 'Pull', Push = 'Push', - ForcePush = 'ForcePush' + ForcePush = 'ForcePush', + Fetch = 'Fetch' } interface IFileDiffArgument { @@ -1423,7 +1424,7 @@ export function addFileBrowserContextMenu( * @returns Promise for displaying a dialog */ export async function showGitOperationDialog( - model: GitExtension, + model: IGitExtension, operation: Operation, trans: TranslationBundle, args?: T, @@ -1448,6 +1449,10 @@ export async function showGitOperationDialog( case Operation.ForcePush: result = await model.push(authentication, true); break; + case Operation.Fetch: + result = await model.fetch(authentication); + model.credentialsRequired = false; + break; default: result = { code: -1, message: 'Unknown git command' }; break; diff --git a/src/components/StatusWidget.tsx b/src/components/StatusWidget.tsx index 880f2881e..9be3e83ac 100644 --- a/src/components/StatusWidget.tsx +++ b/src/components/StatusWidget.tsx @@ -1,15 +1,21 @@ -import { ReactWidget } from '@jupyterlab/apputils'; +import { ReactWidget, UseSignal } from '@jupyterlab/apputils'; import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { IStatusBar } from '@jupyterlab/statusbar'; import { TranslationBundle } from '@jupyterlab/translation'; +import { Badge } from '@material-ui/core'; import React from 'react'; +import { classes } from 'typestyle'; +import { Operation, showGitOperationDialog } from '../commandsAndMenu'; import { gitIcon } from '../style/icons'; import { + badgeClass, statusAnimatedIconClass, statusIconClass } from '../style/StatusWidget'; +import { toolbarButtonClass } from '../style/Toolbar'; import { IGitExtension } from '../tokens'; import { sleep } from '../utils'; +import { ActionButton } from './ActionButton'; export class StatusWidget extends ReactWidget { /** @@ -17,8 +23,9 @@ export class StatusWidget extends ReactWidget { * @param trans - The language translator * @returns widget */ - constructor(trans: TranslationBundle) { + constructor(model: IGitExtension, trans: TranslationBundle) { super(); + this._model = model; this._trans = trans; } @@ -34,19 +41,50 @@ export class StatusWidget extends ReactWidget { render(): JSX.Element { return ( -
- -
+ + {(_, needsCredentials) => ( + + this._showGitOperationDialog() + : undefined + } + title={ + needsCredentials + ? `Git: ${this._trans.__('credentials required')}` + : `Git: ${this._trans.__(this._status)}` + } + /> + + )} + ); } + async _showGitOperationDialog(): Promise { + try { + await showGitOperationDialog(this._model, Operation.Fetch, this._trans); + } catch (error) { + console.error('Encountered an error when fetching. Error:', error); + } + } + /** * Locks the status widget to prevent updates. * @@ -72,6 +110,7 @@ export class StatusWidget extends ReactWidget { */ private _status = ''; + private _model: IGitExtension; private _trans: TranslationBundle; } @@ -82,7 +121,7 @@ export function addStatusBarWidget( trans: TranslationBundle ): void { // Add a status bar widget to provide Git status updates: - const statusWidget = new StatusWidget(trans); + const statusWidget = new StatusWidget(model, trans); statusBar.registerStatusItem('git-status', { align: 'left', item: statusWidget, @@ -92,6 +131,7 @@ export function addStatusBarWidget( const callback = Private.createEventCallback(statusWidget); model.taskChanged.connect(callback); + statusWidget.disposed.connect(() => { model.taskChanged.disconnect(callback); }); diff --git a/src/model.ts b/src/model.ts index 90f610e7b..f7f100c94 100644 --- a/src/model.ts +++ b/src/model.ts @@ -5,7 +5,7 @@ import { ISettingRegistry } from '@jupyterlab/settingregistry'; import { JSONExt, JSONObject } from '@lumino/coreutils'; import { Poll } from '@lumino/polling'; import { ISignal, Signal } from '@lumino/signaling'; -import { requestAPI } from './git'; +import { AUTH_ERROR_MESSAGES, requestAPI } from './git'; import { TaskHandler } from './taskhandler'; import { Git, IGitExtension } from './tokens'; import { decodeStage } from './utils'; @@ -298,6 +298,27 @@ export class GitExtension implements IGitExtension { return this._dirtyStagedFilesStatusChanged; } + /** + * Boolean indicating whether credentials are required from the user. + */ + get credentialsRequired(): boolean { + return this._credentialsRequired; + } + + set credentialsRequired(value: boolean) { + if (this._credentialsRequired !== value) { + this._credentialsRequired = value; + this._credentialsRequiredChanged.emit(value); + } + } + + /** + * A signal emitted whenever credentials are required, or are not required anymore. + */ + get credentialsRequiredChanged(): ISignal { + return this._credentialsRequiredChanged; + } + /** * Get the current markers * @@ -737,6 +758,33 @@ export class GitExtension implements IGitExtension { await this.refreshStatus(); } + /** + * Fetch to get ahead/behind status + * + * @param auth - remote authentication information + * @returns promise which resolves upon fetching + * + * @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 fetch(auth?: Git.IAuth): Promise { + const path = await this._getPathRepository(); + const data = this._taskHandler.execute( + 'git:fetch:remote', + async () => { + return await requestAPI( + URLExt.join(path, 'remote', 'fetch'), + 'POST', + { + auth: auth as any + } + ); + } + ); + return data; + } + /** * Return the path of a file relative to the Jupyter server root. * @@ -1512,13 +1560,23 @@ export class GitExtension implements IGitExtension { /** * Fetch poll action. + * This is blocked if Git credentials are required. */ private _fetchRemotes = async (): Promise => { + if (this.credentialsRequired) { + return; + } try { - const path = await this._getPathRepository(); - await requestAPI(URLExt.join(path, 'remote', 'fetch'), 'POST'); + await this.fetch(); } catch (error) { console.error('Failed to fetch remotes', error); + if ( + AUTH_ERROR_MESSAGES.some( + errorMessage => (error as Error).message.indexOf(errorMessage) > -1 + ) + ) { + this.credentialsRequired = true; + } } }; @@ -1628,6 +1686,7 @@ export class GitExtension implements IGitExtension { private _changeUpstreamNotified: Git.IStatusFile[] = []; private _selectedHistoryFile: Git.IStatusFile | null = null; private _hasDirtyStagedFiles = false; + private _credentialsRequired = false; private _branchesChanged = new Signal(this); private _headChanged = new Signal(this); @@ -1648,6 +1707,9 @@ export class GitExtension implements IGitExtension { private _dirtyStagedFilesStatusChanged = new Signal( this ); + private _credentialsRequiredChanged = new Signal( + this + ); } export class BranchMarker implements Git.IBranchMarker { diff --git a/src/style/StatusWidget.ts b/src/style/StatusWidget.ts index 8eac4483d..c00d0d4a8 100644 --- a/src/style/StatusWidget.ts +++ b/src/style/StatusWidget.ts @@ -28,3 +28,13 @@ export const statusAnimatedIconClass = style({ } } }); + +export const badgeClass = style({ + $nest: { + '& > .MuiBadge-badge': { + top: 6, + right: 15, + backgroundColor: 'var(--jp-warn-color1)' + } + } +}); diff --git a/src/tokens.ts b/src/tokens.ts index 0ac781bd0..f82d3ed3d 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -72,6 +72,16 @@ export interface IGitExtension extends IDisposable { */ hasDirtyStagedFiles: boolean; + /** + * Boolean indicating whether credentials are required from the user. + */ + credentialsRequired: boolean; + + /** + * A signal emitted whenever credentials are required, or are not required anymore. + */ + readonly credentialsRequiredChanged: ISignal; + /** * Git repository status. */ @@ -290,6 +300,18 @@ export interface IGitExtension extends IDisposable { */ ensureGitignore(): Promise; + /** + * Fetch to get ahead/behind status + * + * @param auth - remote authentication information + * @returns promise which resolves upon fetching + * + * @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 + */ + fetch(auth?: Git.IAuth): Promise; + /** * Match files status information based on a provided file path. * @@ -381,13 +403,14 @@ export interface IGitExtension extends IDisposable { * Push local changes to a remote repository. * * @param auth - remote authentication information + * @param force - whether or not to force the push * @returns promise which resolves upon pushing changes * * @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 */ - push(auth?: Git.IAuth): Promise; + push(auth?: Git.IAuth, force?: boolean): Promise; /** * General Git refresh @@ -924,11 +947,12 @@ export namespace Git { } /** - * Interface for the Git Auth request. + * Interface for the Git Auth request with credentials caching option. */ export interface IAuth { username: string; password: string; + cache_credentials?: boolean; } /** diff --git a/src/widgets/CredentialsBox.tsx b/src/widgets/CredentialsBox.tsx index e20d4b337..9e39c24e7 100755 --- a/src/widgets/CredentialsBox.tsx +++ b/src/widgets/CredentialsBox.tsx @@ -23,15 +23,21 @@ export class GitCredentialsForm private createBody(textContent: string, warningContent: string): HTMLElement { const node = document.createElement('div'); const label = document.createElement('label'); + + const checkboxLabel = document.createElement('label'); + this._checkboxCacheCredentials = document.createElement('input'); + const checkboxText = document.createElement('span'); + this._user = document.createElement('input'); + this._user.type = 'text'; this._password = document.createElement('input'); this._password.type = 'password'; const text = document.createElement('span'); const warning = document.createElement('div'); - node.className = 'jp-RedirectForm'; - warning.className = 'jp-RedirectForm-warning'; + node.className = 'jp-CredentialsBox'; + warning.className = 'jp-CredentialsBox-warning'; text.textContent = textContent; warning.textContent = warningContent; this._user.placeholder = this._trans.__('username'); @@ -39,11 +45,20 @@ export class GitCredentialsForm 'password / personal access token' ); + checkboxLabel.className = 'jp-CredentialsBox-label-checkbox'; + this._checkboxCacheCredentials.type = 'checkbox'; + checkboxText.textContent = this._trans.__('Save my login temporarily'); + label.appendChild(text); label.appendChild(this._user); label.appendChild(this._password); node.appendChild(label); node.appendChild(warning); + + checkboxLabel.appendChild(this._checkboxCacheCredentials); + checkboxLabel.appendChild(checkboxText); + node.appendChild(checkboxLabel); + return node; } @@ -53,10 +68,12 @@ export class GitCredentialsForm getValue(): Git.IAuth { return { username: this._user.value, - password: this._password.value + password: this._password.value, + cache_credentials: this._checkboxCacheCredentials.checked }; } protected _trans: TranslationBundle; private _user: HTMLInputElement; private _password: HTMLInputElement; + private _checkboxCacheCredentials: HTMLInputElement; } diff --git a/style/base.css b/style/base.css index af3ce1e8c..7bbb128ca 100644 --- a/style/base.css +++ b/style/base.css @@ -3,6 +3,7 @@ | Distributed under the terms of the Modified BSD License. |----------------------------------------------------------------------------*/ +@import url('credentials-box.css'); @import url('diff-common.css'); @import url('diff-nb.css'); @import url('diff-text.css'); diff --git a/style/credentials-box.css b/style/credentials-box.css new file mode 100644 index 000000000..68f11f3fe --- /dev/null +++ b/style/credentials-box.css @@ -0,0 +1,20 @@ +.jp-CredentialsBox input[type='text'], +.jp-CredentialsBox input[type='password'] { + display: block; + width: 100%; + margin-top: 10px; + margin-bottom: 10px; +} + +.jp-CredentialsBox input[type='checkbox'] { + display: inline-block; +} + +.jp-CredentialsBox-warning { + color: var(--jp-warn-color0); +} + +.jp-CredentialsBox-label-checkbox { + display: flex; + align-items: center; +}