Skip to content

Commit a7e5707

Browse files
authored
Guess default push target (#721)
* Fresh rebase of #412 * Add Python logger * Correct api response handling for push/pull/tag * Reverse tag order to put newer on top * Fix Python 3.5 error
1 parent 6eb994e commit a7e5707

File tree

9 files changed

+356
-111
lines changed

9 files changed

+356
-111
lines changed

jupyterlab_git/git.py

Lines changed: 51 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@
1313
import tornado.locks
1414
import datetime
1515

16+
from .log import get_logger
17+
1618

17-
# Git configuration options exposed through the REST API
18-
ALLOWED_OPTIONS = ['user.name', 'user.email']
1919
# Regex pattern to capture (key, value) of Git configuration options.
2020
# See https://git-scm.com/docs/git-config#_syntax for git var syntax
2121
CONFIG_PATTERN = re.compile(r"(?:^|\n)([\w\-\.]+)\=")
2222
DEFAULT_REMOTE_NAME = "origin"
23+
# Maximum number of character of command output to print in debug log
24+
MAX_LOG_OUTPUT = 500 # type: int
2325
# How long to wait to be executed or finished your execution before timing out
2426
MAX_WAIT_FOR_EXECUTE_S = 20
2527
# Ensure on NFS or similar, that we give the .git/index.lock time to be removed
@@ -100,7 +102,7 @@ def call_subprocess(
100102

101103
try:
102104
await execution_lock.acquire(timeout=datetime.timedelta(seconds=MAX_WAIT_FOR_EXECUTE_S))
103-
except tornado.util.TimeoutError:
105+
except tornado.util.TimeoutError:
104106
return (1, "", "Unable to get the lock on the directory")
105107

106108
try:
@@ -113,6 +115,7 @@ def call_subprocess(
113115

114116
# If the lock still exists at this point, we will likely fail anyway, but let's try anyway
115117

118+
get_logger().debug("Execute {!s} in {!s}.".format(cmdline, cwd))
116119
if username is not None and password is not None:
117120
code, output, error = await call_subprocess_with_authentication(
118121
cmdline,
@@ -126,6 +129,11 @@ def call_subprocess(
126129
code, output, error = await current_loop.run_in_executor(
127130
None, call_subprocess, cmdline, cwd, env
128131
)
132+
log_output = output[:MAX_LOG_OUTPUT] + "..." if len(output) > MAX_LOG_OUTPUT else output
133+
log_error = error[:MAX_LOG_OUTPUT] + "..." if len(error) > MAX_LOG_OUTPUT else error
134+
get_logger().debug("Code: {}\nOutput: {}\nError: {}".format(code, log_output, log_error))
135+
except BaseException:
136+
get_logger().warning("Fail to execute {!s}".format(cmdline), exc_info=True)
129137
finally:
130138
execution_lock.release()
131139

@@ -158,9 +166,7 @@ async def config(self, top_repo_path, **kwargs):
158166

159167
if len(kwargs):
160168
output = []
161-
for k, v in filter(
162-
lambda t: True if t[0] in ALLOWED_OPTIONS else False, kwargs.items()
163-
):
169+
for k, v in kwargs.items():
164170
cmd = ["git", "config", "--add", k, v]
165171
code, out, err = await execute(cmd, cwd=top_repo_path)
166172
output.append(out.strip())
@@ -182,7 +188,7 @@ async def config(self, top_repo_path, **kwargs):
182188
else:
183189
raw = output.strip()
184190
s = CONFIG_PATTERN.split(raw)
185-
response["options"] = {k:v for k, v in zip(s[1::2], s[2::2]) if k in ALLOWED_OPTIONS}
191+
response["options"] = {k:v for k, v in zip(s[1::2], s[2::2])}
186192

187193
return response
188194

@@ -841,15 +847,20 @@ async def pull(self, curr_fb_path, auth=None, cancel_on_conflict=False):
841847

842848
return response
843849

844-
async def push(self, remote, branch, curr_fb_path, auth=None):
850+
async def push(self, remote, branch, curr_fb_path, auth=None, set_upstream=False):
845851
"""
846852
Execute `git push $UPSTREAM $BRANCH`. The choice of upstream and branch is up to the caller.
847-
"""
853+
"""
854+
command = ["git", "push"]
855+
if set_upstream:
856+
command.append("--set-upstream")
857+
command.extend([remote, branch])
858+
848859
env = os.environ.copy()
849860
if auth:
850861
env["GIT_TERMINAL_PROMPT"] = "1"
851862
code, _, error = await execute(
852-
["git", "push", remote, branch],
863+
command,
853864
username=auth["username"],
854865
password=auth["password"],
855866
cwd=os.path.join(self.root_dir, curr_fb_path),
@@ -858,7 +869,7 @@ async def push(self, remote, branch, curr_fb_path, auth=None):
858869
else:
859870
env["GIT_TERMINAL_PROMPT"] = "0"
860871
code, _, error = await execute(
861-
["git", "push", remote, branch],
872+
command,
862873
env=env,
863874
cwd=os.path.join(self.root_dir, curr_fb_path),
864875
)
@@ -1125,7 +1136,7 @@ async def _is_binary(self, filename, ref, top_repo_path):
11251136
# For binary files, `--numstat` outputs two `-` characters separated by TABs:
11261137
return output.startswith('-\t-\t')
11271138

1128-
def remote_add(self, top_repo_path, url, name=DEFAULT_REMOTE_NAME):
1139+
async def remote_add(self, top_repo_path, url, name=DEFAULT_REMOTE_NAME):
11291140
"""Handle call to `git remote add` command.
11301141
11311142
top_repo_path: str
@@ -1136,19 +1147,37 @@ def remote_add(self, top_repo_path, url, name=DEFAULT_REMOTE_NAME):
11361147
Remote name; default "origin"
11371148
"""
11381149
cmd = ["git", "remote", "add", name, url]
1139-
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=top_repo_path)
1140-
_, my_error = p.communicate()
1141-
if p.returncode == 0:
1142-
return {
1143-
"code": p.returncode,
1150+
code, _, error = await execute(cmd, cwd=top_repo_path)
1151+
response = {
1152+
"code": code,
11441153
"command": " ".join(cmd)
11451154
}
1146-
else:
1147-
return {
1148-
"code": p.returncode,
1149-
"command": " ".join(cmd),
1150-
"message": my_error.decode("utf-8").strip()
1155+
1156+
if code != 0:
1157+
response["message"] = error
1158+
1159+
return response
1160+
1161+
async def remote_show(self, path):
1162+
"""Handle call to `git remote show` command.
1163+
Args:
1164+
path (str): Git repository path
1165+
1166+
Returns:
1167+
List[str]: Known remotes
1168+
"""
1169+
command = ["git", "remote", "show"]
1170+
code, output, error = await execute(command, cwd=path)
1171+
response = {
1172+
"code": code,
1173+
"command": " ".join(command)
11511174
}
1175+
if code == 0:
1176+
response["remotes"] = [r.strip() for r in output.splitlines()]
1177+
else:
1178+
response["message"] = error
1179+
1180+
return response
11521181

11531182
async def ensure_gitignore(self, top_repo_path):
11541183
"""Handle call to ensure .gitignore file exists and the

jupyterlab_git/handlers.py

Lines changed: 75 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@
1313
from ._version import __version__
1414
from .git import DEFAULT_REMOTE_NAME
1515

16+
17+
# Git configuration options exposed through the REST API
18+
ALLOWED_OPTIONS = ['user.name', 'user.email']
19+
20+
1621
class GitHandler(APIHandler):
1722
"""
1823
Top-level parent class.
@@ -265,13 +270,14 @@ async def post(self):
265270
class GitRemoteAddHandler(GitHandler):
266271
"""Handler for 'git remote add <name> <url>'."""
267272

268-
def post(self):
273+
@web.authenticated
274+
async def post(self):
269275
"""POST request handler to add a remote."""
270276
data = self.get_json_body()
271277
top_repo_path = data["top_repo_path"]
272278
name = data.get("name", DEFAULT_REMOTE_NAME)
273279
url = data["url"]
274-
output = self.git.remote_add(top_repo_path, url, name)
280+
output = await self.git.remote_add(top_repo_path, url, name)
275281
if(output["code"] == 0):
276282
self.set_status(201)
277283
else:
@@ -422,6 +428,9 @@ async def post(self):
422428
data = self.get_json_body()
423429
response = await self.git.pull(data["current_path"], data.get("auth", None), data.get("cancel_on_conflict", False))
424430

431+
if response["code"] != 0:
432+
self.set_status(500)
433+
425434
self.finish(json.dumps(response))
426435

427436

@@ -436,32 +445,76 @@ async def post(self):
436445
"""
437446
POST request handler,
438447
pushes committed files from your current branch to a remote branch
448+
449+
Request body:
450+
{
451+
current_path: string, # Git repository path
452+
remote?: string # Remote to push to; i.e. <remote_name> or <remote_name>/<branch>
453+
}
439454
"""
440455
data = self.get_json_body()
441456
current_path = data["current_path"]
457+
known_remote = data.get("remote")
442458

443459
current_local_branch = await self.git.get_current_branch(current_path)
444-
upstream = await self.git.get_upstream_branch(
460+
461+
set_upstream = False
462+
current_upstream_branch = await self.git.get_upstream_branch(
445463
current_path, current_local_branch
446464
)
447465

448-
if upstream['code'] == 0:
449-
branch = ":".join(["HEAD", upstream['remote_branch']])
466+
if known_remote is not None:
467+
set_upstream = current_upstream_branch['code'] != 0
468+
469+
remote_name, _, remote_branch = known_remote.partition("/")
470+
471+
current_upstream_branch = {
472+
"code": 0,
473+
"remote_branch": remote_branch or current_local_branch,
474+
"remote_short_name": remote_name
475+
}
476+
477+
if current_upstream_branch['code'] == 0:
478+
branch = ":".join(["HEAD", current_upstream_branch['remote_branch']])
450479
response = await self.git.push(
451-
upstream['remote_short_name'], branch, current_path, data.get("auth", None)
480+
current_upstream_branch['remote_short_name'], branch, current_path, data.get("auth", None), set_upstream
452481
)
453482

454483
else:
455-
if ("no upstream configured for branch" in upstream['message'].lower()
456-
or 'unknown revision or path' in upstream['message'].lower()):
484+
# Allow users to specify upstream through their configuration
485+
# https://git-scm.com/docs/git-config#Documentation/git-config.txt-pushdefault
486+
# Or use the remote defined if only one remote exists
487+
config = await self.git.config(current_path)
488+
config_options = config["options"]
489+
list_remotes = await self.git.remote_show(current_path)
490+
remotes = list_remotes.get("remotes", list())
491+
push_default = config_options.get('remote.pushdefault')
492+
493+
default_remote = None
494+
if push_default is not None and push_default in remotes:
495+
default_remote = push_default
496+
elif len(remotes) == 1:
497+
default_remote = remotes[0]
498+
499+
if default_remote is not None:
500+
response = await self.git.push(
501+
default_remote,
502+
current_local_branch,
503+
current_path,
504+
data.get("auth", None),
505+
set_upstream=True,
506+
)
507+
else:
457508
response = {
458509
"code": 128,
459510
"message": "fatal: The current branch {} has no upstream branch.".format(
460511
current_local_branch
461512
),
513+
"remotes": remotes # Returns the list of known remotes
462514
}
463-
else:
464-
self.set_status(500)
515+
516+
if response["code"] != 0:
517+
self.set_status(500)
465518

466519
self.finish(json.dumps(response))
467520

@@ -504,8 +557,12 @@ async def post(self):
504557
"""
505558
data = self.get_json_body()
506559
top_repo_path = data["path"]
507-
options = data.get("options", {})
508-
response = await self.git.config(top_repo_path, **options)
560+
options = data.get("options", {})
561+
562+
filtered_options = {k: v for k, v in options.items() if k in ALLOWED_OPTIONS}
563+
response = await self.git.config(top_repo_path, **filtered_options)
564+
if "options" in response:
565+
response["options"] = {k:v for k, v in response["options"].items() if k in ALLOWED_OPTIONS}
509566

510567
if response["code"] != 0:
511568
self.set_status(500)
@@ -601,6 +658,9 @@ async def post(self):
601658
"""
602659
current_path = self.get_json_body()["current_path"]
603660
result = await self.git.tags(current_path)
661+
662+
if result["code"] != 0:
663+
self.set_status(500)
604664
self.finish(json.dumps(result))
605665

606666

@@ -618,6 +678,9 @@ async def post(self):
618678
current_path = data["current_path"]
619679
tag = data["tag_id"]
620680
result = await self.git.tag_checkout(current_path, tag)
681+
682+
if result["code"] != 0:
683+
self.set_status(500)
621684
self.finish(json.dumps(result))
622685

623686

jupyterlab_git/log.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import logging
2+
3+
from traitlets.config import Application
4+
5+
6+
class _ExtensionLogger:
7+
_LOGGER = None # type: Optional[logging.Logger]
8+
9+
@classmethod
10+
def get_logger(cls) -> logging.Logger:
11+
if cls._LOGGER is None:
12+
app = Application.instance()
13+
cls._LOGGER = logging.getLogger("{!s}.jupyterlab_git".format(app.log.name))
14+
15+
return cls._LOGGER
16+
17+
18+
get_logger = _ExtensionLogger.get_logger

jupyterlab_git/tests/test_config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ def test_git_get_config_multiline(self, mock_execute):
7575

7676
@patch("jupyterlab_git.git.execute")
7777
@patch(
78-
"jupyterlab_git.git.ALLOWED_OPTIONS",
78+
"jupyterlab_git.handlers.ALLOWED_OPTIONS",
7979
["alias.summary", "alias.topic-base-branch-name"],
8080
)
8181
def test_git_get_config_accepted_multiline(self, mock_execute):

0 commit comments

Comments
 (0)