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 23 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 3600 seconds (1 hour).
quachtridat marked this conversation as resolved.
Show resolved Hide resolved
""",
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
206 changes: 200 additions & 6 deletions jupyterlab_git/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
import re
import shlex
import subprocess
import sys
import traceback
from typing import Any, Dict, List, Optional
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,6 +178,8 @@ 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

Expand Down Expand Up @@ -261,7 +267,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):
async def clone(self, path, repo_url, auth=None, cache_credentials=None):
quachtridat marked this conversation as resolved.
Show resolved Hide resolved
"""
Execute `git clone`.
When no auth is provided, disables prompts for the password to avoid the terminal hanging.
Expand All @@ -273,6 +279,13 @@ async def clone(self, path, repo_url, auth=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!"),
}
env["GIT_TERMINAL_PROMPT"] = "1"
code, output, error = await execute(
["git", "clone", unquote(repo_url), "-q"],
Expand All @@ -296,7 +309,7 @@ async def clone(self, path, repo_url, auth=None):

return response

async def fetch(self, path):
async def fetch(self, path, auth=None, cache_credentials=False):
"""
Execute git fetch command
"""
Expand All @@ -308,15 +321,34 @@ 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 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!"),
}
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 @@ -998,13 +1030,22 @@ 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):
async def pull(
self, path, auth=None, cancel_on_conflict=False, cache_credentials=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!"),
}
env["GIT_TERMINAL_PROMPT"] = "1"
code, output, error = await execute(
["git", "pull", "--no-commit"],
Expand Down Expand Up @@ -1048,7 +1089,14 @@ 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,
cache_credentials=False,
):
"""
Execute `git push $UPSTREAM $BRANCH`. The choice of upstream and branch is up to the caller.
Expand All @@ -1062,6 +1110,13 @@ 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!"),
}
env["GIT_TERMINAL_PROMPT"] = "1"
code, output, error = await execute(
command,
Expand Down Expand Up @@ -1540,3 +1595,142 @@ async def tag_checkout(self, path, tag):
"command": " ".join(command),
"message": error,
}

async def check_credential_helper(self, path: str) -> Dict[str, Any]:
quachtridat marked this conversation as resolved.
Show resolved Hide resolved
git_config_response: Dict[str, str] = await self.config(path)
if git_config_response["code"] != 0:
return git_config_response

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 has_credential_helper and GIT_CREDENTIAL_HELPER_CACHE.match(
git_config_kv_pairs["credential.helper"].strip()
):
response["cache_daemon_required"] = True

return response

async def ensure_credential_helper(
self, path: str, env: Dict[str, str] = None
) -> Dict[str, Any]:
"""
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
"""
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
)

response = {"code": -1}

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"):
quachtridat marked this conversation as resolved.
Show resolved Hide resolved
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

def ensure_git_credential_cache_daemon(
self,
socket: Optional[str] = None,
debug: bool = False,
new: bool = False,
quachtridat marked this conversation as resolved.
Show resolved Hide resolved
cwd: Optional[str] = None,
env: Dict[str, str] = None,
) -> int:
"""
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,
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.
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"
)
quachtridat marked this conversation as resolved.
Show resolved Hide resolved

if socket and os.path.exists(socket):
quachtridat marked this conversation as resolved.
Show resolved Hide resolved
return -1

if self._GIT_CREDENTIAL_CACHE_DAEMON_PROCESS is None or new is True:
quachtridat marked this conversation as resolved.
Show resolved Hide resolved

if new is True and self._GIT_CREDENTIAL_CACHE_DAEMON_PROCESS:
quachtridat marked this conversation as resolved.
Show resolved Hide resolved
self._GIT_CREDENTIAL_CACHE_DAEMON_PROCESS.terminate()

if not socket:
raise ValueError()
quachtridat marked this conversation as resolved.
Show resolved Hide resolved

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)
quachtridat marked this conversation as resolved.
Show resolved Hide resolved

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 %s",
str(self._GIT_CREDENTIAL_CACHE_DAEMON_PROCESS.pid),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"A credential cache daemon has been spawned with PID %s",
str(self._GIT_CREDENTIAL_CACHE_DAEMON_PROCESS.pid),
f"A credential cache daemon has been spawned with PID {self._GIT_CREDENTIAL_CACHE_DAEMON_PROCESS.pid}"

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain why we are using f-strings with loggers? I thought that it is recommended to do the %d or %s "format" way.

)

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
quachtridat marked this conversation as resolved.
Show resolved Hide resolved

def __del__(self):
if self._GIT_CREDENTIAL_CACHE_DAEMON_PROCESS:
self._GIT_CREDENTIAL_CACHE_DAEMON_PROCESS.terminate()
quachtridat marked this conversation as resolved.
Show resolved Hide resolved
18 changes: 15 additions & 3 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"]
ALLOWED_OPTIONS = ["user.name", "user.email", "credential.helper"]
quachtridat marked this conversation as resolved.
Show resolved Hide resolved
# REST API namespace
NAMESPACE = "/git"

Expand Down Expand Up @@ -73,7 +73,10 @@ async def post(self, path: str = ""):
"""
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),
data.get("cache_credentials", False),
quachtridat marked this conversation as resolved.
Show resolved Hide resolved
)

if response["code"] != 0:
Expand Down Expand Up @@ -170,7 +173,12 @@ 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),
data.get("cache_credentials", False),
)

if result["code"] != 0:
self.set_status(500)
Expand Down Expand Up @@ -533,6 +541,7 @@ 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 @@ -563,6 +572,7 @@ 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 @@ -591,6 +601,7 @@ async def post(self, path: str = ""):
data.get("auth", None),
set_upstream,
force,
cache_credentials,
)

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