Skip to content

Commit 0e1e697

Browse files
authored
Add indicators on push/pull icons if actions is required (jupyterlab#813)
* Add badge on push/pull icons * Add badges on push/pull if action is required * correct test status * Fix jest tests * Update jest * Test badges * Downgrade jest to ^25.0.0 * Add fetch before status * Add badge on push for new branch with no upstream * Fixes jupyterlab#582 * Improve styling * Fix unit tests * Use separate Poll to fetch * Clear signal when component unmounts * Set model default values coherent of a clear state * Fix unit tests
1 parent 5768a3a commit 0e1e697

18 files changed

+2205
-1491
lines changed

jest.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ module.exports = {
1919
transformIgnorePatterns: ['/node_modules/(?!(@jupyterlab/.*)/)'],
2020
globals: {
2121
'ts-jest': {
22-
tsConfig: tsOptions
22+
tsconfig: tsOptions
2323
}
2424
}
2525
};

jupyterlab_git/git.py

Lines changed: 84 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@
3030
CHECK_LOCK_INTERVAL_S = 0.1
3131
# Parse Git version output
3232
GIT_VERSION_REGEX = re.compile(r"^git\sversion\s(?P<version>\d+(.\d+)*)")
33+
# Parse Git branch status
34+
GIT_BRANCH_STATUS = re.compile(
35+
r"^## (?P<branch>([\w\-/]+|HEAD \(no branch\)|No commits yet on \w+))(\.\.\.(?P<remote>[\w\-/]+)( \[(ahead (?P<ahead>\d+))?(, )?(behind (?P<behind>\d+))?\])?)?$"
36+
)
3337

3438
execution_lock = tornado.locks.Lock()
3539

@@ -272,7 +276,7 @@ async def clone(self, current_path, repo_url, auth=None):
272276
env = os.environ.copy()
273277
if auth:
274278
env["GIT_TERMINAL_PROMPT"] = "1"
275-
code, _, error = await execute(
279+
code, output, error = await execute(
276280
["git", "clone", unquote(repo_url), "-q"],
277281
username=auth["username"],
278282
password=auth["password"],
@@ -281,28 +285,50 @@ async def clone(self, current_path, repo_url, auth=None):
281285
)
282286
else:
283287
env["GIT_TERMINAL_PROMPT"] = "0"
284-
code, _, error = await execute(
288+
code, output, error = await execute(
285289
["git", "clone", unquote(repo_url)],
286290
cwd=os.path.join(self.root_dir, current_path),
287291
env=env,
288292
)
289293

290-
response = {"code": code}
294+
response = {"code": code, "message": output.strip()}
291295

292296
if code != 0:
293297
response["message"] = error.strip()
294298

295299
return response
296300

301+
async def fetch(self, current_path):
302+
"""
303+
Execute git fetch command
304+
"""
305+
cwd = os.path.join(self.root_dir, current_path)
306+
# Start by fetching to get accurate ahead/behind status
307+
cmd = [
308+
"git",
309+
"fetch",
310+
"--all",
311+
"--prune",
312+
] # Run prune by default to help beginners
313+
314+
code, _, fetch_error = await execute(cmd, cwd=cwd)
315+
316+
result = {
317+
"code": code,
318+
}
319+
if code != 0:
320+
result["command"] = " ".join(cmd)
321+
result["error"] = fetch_error
322+
323+
return result
324+
297325
async def status(self, current_path):
298326
"""
299327
Execute git status command & return the result.
300328
"""
301-
cmd = ["git", "status", "--porcelain", "-u", "-z"]
302-
code, my_output, my_error = await execute(
303-
cmd,
304-
cwd=os.path.join(self.root_dir, current_path),
305-
)
329+
cwd = os.path.join(self.root_dir, current_path)
330+
cmd = ["git", "status", "--porcelain", "-b", "-u", "-z"]
331+
code, status, my_error = await execute(cmd, cwd=cwd)
306332

307333
if code != 0:
308334
return {
@@ -320,32 +346,60 @@ async def status(self, current_path):
320346
"--cached",
321347
"4b825dc642cb6eb9a060e54bf8d69288fbee4904",
322348
]
323-
text_code, text_output, _ = await execute(
324-
command,
325-
cwd=os.path.join(self.root_dir, current_path),
326-
)
349+
text_code, text_output, _ = await execute(command, cwd=cwd)
327350

328351
are_binary = dict()
329352
if text_code == 0:
330353
for line in filter(lambda l: len(l) > 0, strip_and_split(text_output)):
331354
diff, name = line.rsplit("\t", maxsplit=1)
332355
are_binary[name] = diff.startswith("-\t-")
333356

357+
data = {
358+
"code": code,
359+
"branch": None,
360+
"remote": None,
361+
"ahead": 0,
362+
"behind": 0,
363+
"files": [],
364+
}
334365
result = []
335-
line_iterable = (line for line in strip_and_split(my_output) if line)
336-
for line in line_iterable:
337-
name = line[3:]
338-
result.append(
339-
{
340-
"x": line[0],
341-
"y": line[1],
342-
"to": name,
343-
# if file was renamed, next line contains original path
344-
"from": next(line_iterable) if line[0] == "R" else name,
345-
"is_binary": are_binary.get(name, None),
346-
}
347-
)
348-
return {"code": code, "files": result}
366+
line_iterable = (line for line in strip_and_split(status) if line)
367+
368+
try:
369+
first_line = next(line_iterable)
370+
# Interpret branch line
371+
match = GIT_BRANCH_STATUS.match(first_line)
372+
if match is not None:
373+
d = match.groupdict()
374+
branch = d.get("branch")
375+
if branch == "HEAD (no branch)":
376+
branch = "(detached)"
377+
elif branch.startswith("No commits yet on "):
378+
branch = "(initial)"
379+
data["branch"] = branch
380+
data["remote"] = d.get("remote")
381+
data["ahead"] = int(d.get("ahead") or 0)
382+
data["behind"] = int(d.get("behind") or 0)
383+
384+
# Interpret file lines
385+
for line in line_iterable:
386+
name = line[3:]
387+
result.append(
388+
{
389+
"x": line[0],
390+
"y": line[1],
391+
"to": name,
392+
# if file was renamed, next line contains original path
393+
"from": next(line_iterable) if line[0] == "R" else name,
394+
"is_binary": are_binary.get(name, None),
395+
}
396+
)
397+
398+
data["files"] = result
399+
except StopIteration: # Raised if line_iterable is empty
400+
pass
401+
402+
return data
349403

350404
async def log(self, current_path, history_count=10):
351405
"""
@@ -863,7 +917,7 @@ async def pull(self, curr_fb_path, auth=None, cancel_on_conflict=False):
863917
cwd=os.path.join(self.root_dir, curr_fb_path),
864918
)
865919

866-
response = {"code": code}
920+
response = {"code": code, "message": output.strip()}
867921

868922
if code != 0:
869923
output = output.strip()
@@ -901,7 +955,7 @@ async def push(self, remote, branch, curr_fb_path, auth=None, set_upstream=False
901955
env = os.environ.copy()
902956
if auth:
903957
env["GIT_TERMINAL_PROMPT"] = "1"
904-
code, _, error = await execute(
958+
code, output, error = await execute(
905959
command,
906960
username=auth["username"],
907961
password=auth["password"],
@@ -910,13 +964,13 @@ async def push(self, remote, branch, curr_fb_path, auth=None, set_upstream=False
910964
)
911965
else:
912966
env["GIT_TERMINAL_PROMPT"] = "0"
913-
code, _, error = await execute(
967+
code, output, error = await execute(
914968
command,
915969
env=env,
916970
cwd=os.path.join(self.root_dir, curr_fb_path),
917971
)
918972

919-
response = {"code": code}
973+
response = {"code": code, "message": output.strip()}
920974

921975
if code != 0:
922976
response["message"] = error.strip()

jupyterlab_git/handlers.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,24 @@ async def post(self):
134134
self.finish(json.dumps(result))
135135

136136

137+
class GitFetchHandler(GitHandler):
138+
"""
139+
Handler for 'git fetch'
140+
"""
141+
142+
@web.authenticated
143+
async def post(self):
144+
"""
145+
POST request handler, fetch from remotes.
146+
"""
147+
current_path = self.get_json_body()["current_path"]
148+
result = await self.git.fetch(current_path)
149+
150+
if result["code"] != 0:
151+
self.set_status(500)
152+
self.finish(json.dumps(result))
153+
154+
137155
class GitStatusHandler(GitHandler):
138156
"""
139157
Handler for 'git status --porcelain', fetches the git status.
@@ -787,6 +805,7 @@ def setup_handlers(web_app):
787805
("/git/pull", GitPullHandler),
788806
("/git/push", GitPushHandler),
789807
("/git/remote/add", GitRemoteAddHandler),
808+
("/git/remote/fetch", GitFetchHandler),
790809
("/git/reset", GitResetHandler),
791810
("/git/reset_to_commit", GitResetToCommitHandler),
792811
("/git/server_root", GitServerRootHandler),

jupyterlab_git/tests/test_clone.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ async def test_git_clone_success():
1414
with patch("os.environ", {"TEST": "test"}):
1515
with patch("jupyterlab_git.git.execute") as mock_execute:
1616
# Given
17-
mock_execute.return_value = maybe_future((0, "output", "error"))
17+
output = "output"
18+
mock_execute.return_value = maybe_future((0, output, "error"))
1819

1920
# When
2021
actual_response = await Git(FakeContentManager("/bin")).clone(
@@ -27,7 +28,7 @@ async def test_git_clone_success():
2728
cwd=os.path.join("/bin", "test_curr_path"),
2829
env={"TEST": "test", "GIT_TERMINAL_PROMPT": "0"},
2930
)
30-
assert {"code": 0} == actual_response
31+
assert {"code": 0, "message": output} == actual_response
3132

3233

3334
@pytest.mark.asyncio
@@ -66,7 +67,8 @@ async def test_git_clone_with_auth_success():
6667
with patch("os.environ", {"TEST": "test"}):
6768
with patch("jupyterlab_git.git.execute") as mock_authentication:
6869
# Given
69-
mock_authentication.return_value = maybe_future((0, "", ""))
70+
output = "output"
71+
mock_authentication.return_value = maybe_future((0, output, ""))
7072

7173
# When
7274
auth = {"username": "asdf", "password": "qwerty"}
@@ -82,7 +84,7 @@ async def test_git_clone_with_auth_success():
8284
cwd=os.path.join("/bin", "test_curr_path"),
8385
env={"TEST": "test", "GIT_TERMINAL_PROMPT": "1"},
8486
)
85-
assert {"code": 0} == actual_response
87+
assert {"code": 0, "message": output} == actual_response
8688

8789

8890
@pytest.mark.asyncio

jupyterlab_git/tests/test_pushpull.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,8 @@ async def test_git_pull_success():
105105
with patch("os.environ", {"TEST": "test"}):
106106
with patch("jupyterlab_git.git.execute") as mock_execute:
107107
# Given
108-
mock_execute.return_value = maybe_future((0, "output", ""))
108+
output = "output"
109+
mock_execute.return_value = maybe_future((0, output, ""))
109110

110111
# When
111112
actual_response = await Git(FakeContentManager("/bin")).pull(
@@ -118,7 +119,7 @@ async def test_git_pull_success():
118119
cwd=os.path.join("/bin", "test_curr_path"),
119120
env={"TEST": "test", "GIT_TERMINAL_PROMPT": "0"},
120121
)
121-
assert {"code": 0} == actual_response
122+
assert {"code": 0, "message": output} == actual_response
122123

123124

124125
@pytest.mark.asyncio
@@ -127,8 +128,9 @@ async def test_git_pull_with_auth_success():
127128
with patch("os.environ", {"TEST": "test"}):
128129
with patch("jupyterlab_git.git.execute") as mock_execute_with_authentication:
129130
# Given
131+
output = "output"
130132
mock_execute_with_authentication.return_value = maybe_future(
131-
(0, "", "output")
133+
(0, output, "")
132134
)
133135

134136
# When
@@ -145,7 +147,7 @@ async def test_git_pull_with_auth_success():
145147
cwd=os.path.join("/bin", "test_curr_path"),
146148
env={"TEST": "test", "GIT_TERMINAL_PROMPT": "1"},
147149
)
148-
assert {"code": 0} == actual_response
150+
assert {"code": 0, "message": output} == actual_response
149151

150152

151153
@pytest.mark.asyncio
@@ -248,7 +250,8 @@ async def test_git_push_success():
248250
with patch("os.environ", {"TEST": "test"}):
249251
with patch("jupyterlab_git.git.execute") as mock_execute:
250252
# Given
251-
mock_execute.return_value = maybe_future((0, "output", "does not matter"))
253+
output = "output"
254+
mock_execute.return_value = maybe_future((0, output, "does not matter"))
252255

253256
# When
254257
actual_response = await Git(FakeContentManager("/bin")).push(
@@ -261,7 +264,7 @@ async def test_git_push_success():
261264
cwd=os.path.join("/bin", "test_curr_path"),
262265
env={"TEST": "test", "GIT_TERMINAL_PROMPT": "0"},
263266
)
264-
assert {"code": 0} == actual_response
267+
assert {"code": 0, "message": output} == actual_response
265268

266269

267270
@pytest.mark.asyncio
@@ -270,8 +273,9 @@ async def test_git_push_with_auth_success():
270273
with patch("os.environ", {"TEST": "test"}):
271274
with patch("jupyterlab_git.git.execute") as mock_execute_with_authentication:
272275
# Given
276+
output = "output"
273277
mock_execute_with_authentication.return_value = maybe_future(
274-
(0, "", "does not matter")
278+
(0, output, "does not matter")
275279
)
276280

277281
# When
@@ -288,4 +292,4 @@ async def test_git_push_with_auth_success():
288292
cwd=os.path.join("/bin", "test_curr_path"),
289293
env={"TEST": "test", "GIT_TERMINAL_PROMPT": "1"},
290294
)
291-
assert {"code": 0} == actual_response
295+
assert {"code": 0, "message": output} == actual_response

0 commit comments

Comments
 (0)