Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add the ability to save Git login temporarily #1099

Merged
merged 26 commits into from
Apr 4, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
f3327b7
Adjust git command handlers
quachtridat Mar 8, 2022
38b30ff
Add `credential_helper` to server settings
quachtridat Mar 8, 2022
0bad2a8
Implement logic for setting git credential helper
quachtridat Mar 8, 2022
bf13b83
Extend git commands to support credentials caching
quachtridat Mar 8, 2022
c88d70a
Add tests for the new credentials caching logic
quachtridat Mar 8, 2022
4199e48
Extend `Git.IAuth` and `CredentialsBox`
quachtridat Mar 23, 2022
e718d4b
Extend `clone()`, `push()`, `pull()` in model
quachtridat Mar 23, 2022
9a236c3
Extend model for `_fetchRemotes()`
quachtridat Mar 23, 2022
292b25c
Implement `fetch()`
quachtridat Mar 23, 2022
8e2c780
Remove a redundant member
quachtridat Mar 23, 2022
84bd60e
Extend `showGitOperationDialog()`
quachtridat Mar 23, 2022
996a5e7
Match the `IGitExtension` with `push()` in model
quachtridat Mar 23, 2022
4f0badf
Extend `StatusWidget`
quachtridat Mar 23, 2022
5abed4d
Reuse `showGitOperationDialog()` in `StatusWidget`
quachtridat Mar 23, 2022
71deeec
Add a notification dot to `StatusWidget`
quachtridat Mar 23, 2022
a578272
Implement `ensure_git_credential_cache_daemon()`
quachtridat Mar 23, 2022
2754f04
Extend `check/ensure_credential_helper()`
quachtridat Mar 23, 2022
7ed1908
Refactor `push/pull/fetch/clone`
quachtridat Mar 23, 2022
2a1e7ba
Refactor and adjust tests to adopt new changes
quachtridat Mar 23, 2022
e87b6ff
Include `"message"` in `fetch()`'s response
quachtridat Mar 23, 2022
17eed00
Lint files with ESLint
quachtridat Mar 23, 2022
be51d01
Adjust `test_handlers.py` to adopt new changes
quachtridat Mar 23, 2022
dff54f2
Revert `await` back to `call`
quachtridat Mar 23, 2022
b4ae04a
Adopt PR review suggestions
quachtridat Mar 29, 2022
66421b9
Fix CI
quachtridat Mar 31, 2022
d11c2dd
Reformat `model.ts` with Prettier
quachtridat Mar 31, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion jupyterlab_git/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ class JupyterLabGit(Configurable):
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 3600 seconds (1 hour).
By default it is a in-memory cache of 3600 seconds (1 hour); `cache --timeout=3600`.
""",
config=True,
)
Expand Down
167 changes: 66 additions & 101 deletions jupyterlab_git/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
import re
import shlex
import subprocess
import sys
import traceback
from typing import Any, Dict, List, Optional
from typing import Dict, List, Optional
from unittest.mock import NonCallableMock
from urllib.parse import unquote

import nbformat
Expand Down Expand Up @@ -183,6 +183,10 @@ class Git:
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.

Expand Down Expand Up @@ -267,7 +271,7 @@ async def changed_files(self, path, base=None, remote=None, single_commit=None):

return response

async def clone(self, path, repo_url, auth=None, cache_credentials=None):
async def clone(self, path, repo_url, auth=None):
"""
Execute `git clone`.
When no auth is provided, disables prompts for the password to avoid the terminal hanging.
Expand All @@ -279,13 +283,8 @@ async def clone(self, path, repo_url, auth=None, cache_credentials=None):
"""
env = os.environ.copy()
if auth:
if cache_credentials:
ensured_response = await self.ensure_credential_helper(path)
if ensured_response["code"] != 0:
return {
"code": ensured_response["code"],
"message": ensured_response.get("error", "Unknown error!"),
}
if auth.get("cache_credentials", None):
quachtridat marked this conversation as resolved.
Show resolved Hide resolved
await self.ensure_credential_helper(path)
env["GIT_TERMINAL_PROMPT"] = "1"
code, output, error = await execute(
["git", "clone", unquote(repo_url), "-q"],
Expand All @@ -309,7 +308,7 @@ async def clone(self, path, repo_url, auth=None, cache_credentials=None):

return response

async def fetch(self, path, auth=None, cache_credentials=False):
async def fetch(self, path, auth=None):
"""
Execute git fetch command
"""
Expand All @@ -323,13 +322,8 @@ async def fetch(self, path, auth=None, cache_credentials=False):
] # Run prune by default to help beginners
env = os.environ.copy()
if auth:
if cache_credentials:
ensured_response = await self.ensure_credential_helper(path)
if ensured_response["code"] != 0:
return {
"code": ensured_response["code"],
"message": ensured_response.get("error", "Unknown error!"),
}
if auth.get("cache_credentials", None):
await self.ensure_credential_helper(path)
env["GIT_TERMINAL_PROMPT"] = "1"
code, _, fetch_error = await execute(
cmd,
Expand Down Expand Up @@ -1030,22 +1024,15 @@ async def commit(self, commit_msg, amend, path):
return {"code": code, "command": " ".join(cmd), "message": error}
return {"code": code}

async def pull(
self, path, auth=None, cancel_on_conflict=False, cache_credentials=False
):
async def pull(self, path, auth=None, cancel_on_conflict=False):
"""
Execute git pull --no-commit. Disables prompts for the password to avoid the terminal hanging while waiting
for auth.
"""
env = os.environ.copy()
if auth:
if cache_credentials:
ensured_response = await self.ensure_credential_helper(path)
if ensured_response["code"] != 0:
return {
"code": ensured_response["code"],
"message": ensured_response.get("error", "Unknown error!"),
}
if auth.get("cache_credentials", None):
await self.ensure_credential_helper(path)
env["GIT_TERMINAL_PROMPT"] = "1"
code, output, error = await execute(
["git", "pull", "--no-commit"],
Expand Down Expand Up @@ -1096,7 +1083,6 @@ async def push(
auth=None,
set_upstream=False,
force=False,
cache_credentials=False,
):
"""
Execute `git push $UPSTREAM $BRANCH`. The choice of upstream and branch is up to the caller.
Expand All @@ -1110,13 +1096,8 @@ async def push(

env = os.environ.copy()
if auth:
if cache_credentials:
ensured_response = await self.ensure_credential_helper(path)
if ensured_response["code"] != 0:
return {
"code": ensured_response["code"],
"message": ensured_response.get("error", "Unknown error!"),
}
if auth.get("cache_credentials", None):
await self.ensure_credential_helper(path)
env["GIT_TERMINAL_PROMPT"] = "1"
code, output, error = await execute(
command,
Expand Down Expand Up @@ -1596,29 +1577,39 @@ async def tag_checkout(self, path, tag):
"message": error,
}

async def check_credential_helper(self, path: str) -> Dict[str, Any]:
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:
return git_config_response
raise RuntimeError(git_config_response["message"])

git_config_kv_pairs = git_config_response["options"]
has_credential_helper = "credential.helper" in git_config_kv_pairs

response = {
"code": 0,
"result": has_credential_helper,
}
if not has_credential_helper:
return None

if has_credential_helper and GIT_CREDENTIAL_HELPER_CACHE.match(
git_config_kv_pairs["credential.helper"].strip()
):
response["cache_daemon_required"] = True
return True

return response
return False

async def ensure_credential_helper(
self, path: str, env: Dict[str, str] = None
) -> Dict[str, Any]:
) -> 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`
Expand All @@ -1630,81 +1621,63 @@ async def ensure_credential_helper(
Environment variables
"""
quachtridat marked this conversation as resolved.
Show resolved Hide resolved

check_credential_helper_response = await self.check_credential_helper(path)

if check_credential_helper_response["code"] != 0:
return check_credential_helper_response
has_credential_helper = check_credential_helper_response["result"]

cache_daemon_required = check_credential_helper_response.get(
"cache_daemon_required", False
)
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

response = {"code": -1}
cache_daemon_required = has_credential_helper == True

if not has_credential_helper:
quachtridat marked this conversation as resolved.
Show resolved Hide resolved
credential_helper: str = self._config.credential_helper
response.update(
await self.config(path, **{"credential.helper": credential_helper})
)
if GIT_CREDENTIAL_HELPER_CACHE.match(credential_helper.strip()):
cache_daemon_required = True
else:
response["code"] = 0

# special case: Git credential cache
if cache_daemon_required:
if not sys.platform.startswith("win32"):
try:
self.ensure_git_credential_cache_daemon(cwd=path, env=env)
except Exception as e:
response["code"] = 2
response["error"] = f"Unhandled error: {str(e)}"
else:
response["code"] = 1
response["error"] = "Git credential cache cannot operate on Windows!"

return response
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[str] = None,
socket: Optional[pathlib.Path] = None,
debug: bool = False,
new: bool = False,
force: bool = False,
cwd: Optional[str] = None,
env: Dict[str, str] = None,
) -> int:
) -> 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 `new` is `True`, a daemon will be spawned, and if the daemon process is accessible,
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 `new` is `False`, the PID of the existing daemon process is returned.
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 = os.path.join(
os.path.expanduser("~"), ".git-credential-cache", "socket"
)
socket = pathlib.Path.home() / ".git-credential-cache" / "socket"

if socket and os.path.exists(socket):
return -1
if socket.exists():
return

if self._GIT_CREDENTIAL_CACHE_DAEMON_PROCESS is None or new is True:
if self._GIT_CREDENTIAL_CACHE_DAEMON_PROCESS is None or force:

if new is True and self._GIT_CREDENTIAL_CACHE_DAEMON_PROCESS:
if force and self._GIT_CREDENTIAL_CACHE_DAEMON_PROCESS:
self._GIT_CREDENTIAL_CACHE_DAEMON_PROCESS.terminate()

if not socket:
raise ValueError()

socket_dir = os.path.split(socket)[0]
if socket_dir and len(socket_dir) > 0 and not os.path.isdir(socket_dir):
os.makedirs(socket_dir)
os.chmod(socket_dir, 0o700)
if not socket.parent.exists():
socket.parent.mkdir(parents=True, exist_ok=True)
socket.parent.chmod(0o700)

args: List[str] = ["git", "credential-cache--daemon"]

Expand All @@ -1720,17 +1693,9 @@ def ensure_git_credential_cache_daemon(
)

get_logger().debug(
"A credential cache daemon has been spawned with PID %s",
str(self._GIT_CREDENTIAL_CACHE_DAEMON_PROCESS.pid),
"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():
return self.ensure_git_credential_cache_daemon(
socket, debug, True, cwd, env
)

return self._GIT_CREDENTIAL_CACHE_DAEMON_PROCESS.pid

def __del__(self):
if self._GIT_CREDENTIAL_CACHE_DAEMON_PROCESS:
self._GIT_CREDENTIAL_CACHE_DAEMON_PROCESS.terminate()
self.ensure_git_credential_cache_daemon(socket, debug, True, cwd, env)
11 changes: 3 additions & 8 deletions jupyterlab_git/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from .log import get_logger

# Git configuration options exposed through the REST API
ALLOWED_OPTIONS = ["user.name", "user.email", "credential.helper"]
ALLOWED_OPTIONS = ["user.name", "user.email"]
# REST API namespace
NAMESPACE = "/git"

Expand Down Expand Up @@ -67,7 +67,8 @@ async def post(self, path: str = ""):
{
'repo_url': 'https://github.com/path/to/myrepo',
OPTIONAL 'auth': '{ 'username': '<username>',
'password': '<password>'
'password': '<password>',
'cacheCredentials': true/false
}'
}
"""
Expand All @@ -76,7 +77,6 @@ async def post(self, path: str = ""):
self.url2localpath(path),
data["clone_url"],
data.get("auth", None),
data.get("cache_credentials", False),
)

if response["code"] != 0:
Expand Down Expand Up @@ -177,7 +177,6 @@ async def post(self, path: str = ""):
result = await self.git.fetch(
self.url2localpath(path),
data.get("auth", None),
data.get("cache_credentials", False),
)

if result["code"] != 0:
Expand Down Expand Up @@ -541,7 +540,6 @@ async def post(self, path: str = ""):
self.url2localpath(path),
data.get("auth", None),
data.get("cancel_on_conflict", False),
data.get("cache_credentials", False),
)

if response["code"] != 0:
Expand Down Expand Up @@ -572,7 +570,6 @@ async def post(self, path: str = ""):
data = self.get_json_body()
known_remote = data.get("remote")
force = data.get("force", False)
cache_credentials = data.get("cache_credentials", False)

current_local_branch = await self.git.get_current_branch(local_path)

Expand Down Expand Up @@ -601,7 +598,6 @@ async def post(self, path: str = ""):
data.get("auth", None),
set_upstream,
force,
cache_credentials,
)

else:
Expand All @@ -628,7 +624,6 @@ async def post(self, path: str = ""):
data.get("auth", None),
set_upstream=True,
force=force,
cache_credentials=cache_credentials,
)
else:
response = {
Expand Down
Loading