Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
14 changes: 13 additions & 1 deletion jupyterlab_git/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__
Expand Down Expand Up @@ -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"}]
Expand Down
168 changes: 164 additions & 4 deletions jupyterlab_git/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -37,6 +39,8 @@
GIT_BRANCH_STATUS = re.compile(
r"^## (?P<branch>([\w\-/]+|HEAD \(no branch\)|No commits yet on \w+))(\.\.\.(?P<remote>[\w\-/]+)( \[(ahead (?P<ahead>\d+))?(, )?(behind (?P<behind>\d+))?\])?)?$"
)
# Git cache as a credential helper
GIT_CREDENTIAL_HELPER_CACHE = re.compile(r"cache\b")

execution_lock = tornado.locks.Lock()

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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"],
Expand All @@ -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
"""
Expand All @@ -308,15 +320,29 @@ 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,
}
if code != 0:
result["command"] = " ".join(cmd)
result["error"] = fetch_error
result["message"] = fetch_error

return result

Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -1540,3 +1576,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)
13 changes: 10 additions & 3 deletions jupyterlab_git/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,16 @@ async def post(self, path: str = ""):
{
'repo_url': 'https://github.com/path/to/myrepo',
OPTIONAL 'auth': '{ 'username': '<username>',
'password': '<password>'
'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:
Expand Down Expand Up @@ -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)
Expand Down
Loading