Skip to content

Commit e7cc1ca

Browse files
authored
Merge pull request #548 from ianhi/non-ascii
Allow filenames with non-ascii characters
2 parents 0ded515 + bc69f94 commit e7cc1ca

File tree

4 files changed

+146
-89
lines changed

4 files changed

+146
-89
lines changed

jupyterlab_git/git.py

Lines changed: 61 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,12 @@ def call_subprocess(
101101

102102
return code, output, error
103103

104+
def strip_and_split(s):
105+
"""strip trailing \x00 and split on \x00
106+
107+
Useful for parsing output of git commands with -z flag.
108+
"""
109+
return s.strip("\x00").split("\x00")
104110

105111
class Git:
106112
"""
@@ -113,7 +119,7 @@ def __init__(self, contents_manager):
113119

114120
async def config(self, top_repo_path, **kwargs):
115121
"""Get or set Git options.
116-
122+
117123
If no kwargs, all options are returned. Otherwise kwargs are set.
118124
"""
119125
response = {"code": 1}
@@ -154,12 +160,12 @@ async def changed_files(self, base=None, remote=None, single_commit=None):
154160
There are two reserved "refs" for the base
155161
1. WORKING : Represents the Git working tree
156162
2. INDEX: Represents the Git staging area / index
157-
163+
158164
Keyword Arguments:
159165
single_commit {string} -- The single commit ref
160166
base {string} -- the base Git ref
161167
remote {string} -- the remote Git ref
162-
168+
163169
Returns:
164170
dict -- the response of format {
165171
"code": int, # Command status code
@@ -168,14 +174,14 @@ async def changed_files(self, base=None, remote=None, single_commit=None):
168174
}
169175
"""
170176
if single_commit:
171-
cmd = ["git", "diff", "{}^!".format(single_commit), "--name-only"]
177+
cmd = ["git", "diff", "{}^!".format(single_commit), "--name-only", "-z"]
172178
elif base and remote:
173179
if base == "WORKING":
174-
cmd = ["git", "diff", remote, "--name-only"]
180+
cmd = ["git", "diff", remote, "--name-only", "-z"]
175181
elif base == "INDEX":
176-
cmd = ["git", "diff", "--staged", remote, "--name-only"]
182+
cmd = ["git", "diff", "--staged", remote, "--name-only", "-z"]
177183
else:
178-
cmd = ["git", "diff", base, remote, "--name-only"]
184+
cmd = ["git", "diff", base, remote, "--name-only", "-z"]
179185
else:
180186
raise tornado.web.HTTPError(
181187
400, "Either single_commit or (base and remote) must be provided"
@@ -193,7 +199,7 @@ async def changed_files(self, base=None, remote=None, single_commit=None):
193199
response["command"] = " ".join(cmd)
194200
response["message"] = error
195201
else:
196-
response["files"] = output.strip().split("\n")
202+
response["files"] = strip_and_split(output)
197203

198204
return response
199205

@@ -236,7 +242,7 @@ async def status(self, current_path):
236242
"""
237243
Execute git status command & return the result.
238244
"""
239-
cmd = ["git", "status", "--porcelain", "-u"]
245+
cmd = ["git", "status", "--porcelain", "-u", "-z"]
240246
code, my_output, my_error = await execute(
241247
cmd, cwd=os.path.join(self.root_dir, current_path),
242248
)
@@ -249,20 +255,15 @@ async def status(self, current_path):
249255
}
250256

251257
result = []
252-
line_array = my_output.splitlines()
253-
for line in line_array:
254-
to1 = None
255-
from_path = line[3:]
256-
if line[0] == "R":
257-
to0 = line[3:].split(" -> ")
258-
to1 = to0[len(to0) - 1]
259-
else:
260-
to1 = line[3:]
261-
if to1.startswith('"'):
262-
to1 = to1[1:]
263-
if to1.endswith('"'):
264-
to1 = to1[:-1]
265-
result.append({"x": line[0], "y": line[1], "to": to1, "from": from_path})
258+
line_iterable = iter(strip_and_split(my_output))
259+
for line in line_iterable:
260+
result.append({
261+
"x": line[0],
262+
"y": line[1],
263+
"to": line[3:],
264+
# if file was renamed, next line contains original path
265+
"from": next(line_iterable) if line[0]=='R' else line[3:]
266+
})
266267
return {"code": code, "files": result}
267268

268269
async def log(self, current_path, history_count=10):
@@ -311,73 +312,69 @@ async def log(self, current_path, history_count=10):
311312

312313
async def detailed_log(self, selected_hash, current_path):
313314
"""
314-
Execute git log -1 --stat --numstat --oneline command (used to get
315+
Execute git log -1 --numstat --oneline -z command (used to get
315316
insertions & deletions per file) & return the result.
316317
"""
317-
cmd = ["git", "log", "-1", "--stat", "--numstat", "--oneline", selected_hash]
318+
cmd = ["git", "log", "-1", "--numstat", "--oneline", "-z", selected_hash]
318319
code, my_output, my_error = await execute(
319320
cmd, cwd=os.path.join(self.root_dir, current_path),
320321
)
321322

322323
if code != 0:
323324
return {"code": code, "command": " ".join(cmd), "message": my_error}
324325

326+
total_insertions = 0
327+
total_deletions = 0
325328
result = []
326-
note = [0] * 3
327-
count = 0
328-
temp = ""
329-
line_array = my_output.splitlines()
330-
length = len(line_array)
331-
INSERTION_INDEX = 0
332-
DELETION_INDEX = 1
333-
MODIFIED_FILE_PATH_INDEX = 2
334-
if length > 1:
335-
temp = line_array[length - 1]
336-
words = temp.split()
337-
for i in range(0, len(words)):
338-
if words[i].isdigit():
339-
note[count] = words[i]
340-
count += 1
341-
for num in range(1, int(length / 2)):
342-
line_info = line_array[num].split(maxsplit=2)
343-
words = line_info[2].split("/")
344-
length = len(words)
345-
result.append(
346-
{
347-
"modified_file_path": line_info[MODIFIED_FILE_PATH_INDEX],
348-
"modified_file_name": words[length - 1],
349-
"insertion": line_info[INSERTION_INDEX],
350-
"deletion": line_info[DELETION_INDEX],
351-
}
352-
)
353-
354-
if note[2] == 0 and length > 1:
355-
if "-" in temp:
356-
exchange = note[1]
357-
note[1] = note[2]
358-
note[2] = exchange
329+
line_iterable = iter(strip_and_split(my_output)[1:])
330+
for line in line_iterable:
331+
insertions, deletions, file = line.split('\t')
332+
333+
if file == '':
334+
# file was renamed or moved, we need next two lines of output
335+
from_path = next(line_iterable)
336+
to_path = next(line_iterable)
337+
modified_file_name = from_path + " => " + to_path
338+
modified_file_path = to_path
339+
else:
340+
modified_file_name = file.split("/")[-1]
341+
modified_file_path = file
342+
343+
result.append({
344+
"modified_file_path": modified_file_path,
345+
"modified_file_name": modified_file_name,
346+
"insertion": insertions,
347+
"deletion": deletions,
348+
})
349+
total_insertions += int(insertions)
350+
total_deletions += int(deletions)
351+
352+
modified_file_note = "{num_files} files changed, {insertions} insertions(+), {deletions} deletions(-)".format(
353+
num_files=len(result),
354+
insertions=total_insertions,
355+
deletions=total_deletions)
359356

360357
return {
361358
"code": code,
362-
"modified_file_note": temp,
363-
"modified_files_count": note[0],
364-
"number_of_insertions": note[1],
365-
"number_of_deletions": note[2],
359+
"modified_file_note": modified_file_note,
360+
"modified_files_count": str(len(result)),
361+
"number_of_insertions": str(total_insertions),
362+
"number_of_deletions": str(total_deletions),
366363
"modified_files": result,
367364
}
368365

369366
async def diff(self, top_repo_path):
370367
"""
371368
Execute git diff command & return the result.
372369
"""
373-
cmd = ["git", "diff", "--numstat"]
370+
cmd = ["git", "diff", "--numstat", "-z"]
374371
code, my_output, my_error = await execute(cmd, cwd=top_repo_path)
375372

376373
if code != 0:
377374
return {"code": code, "command": " ".join(cmd), "message": my_error}
378375

379376
result = []
380-
line_array = my_output.splitlines()
377+
line_array = strip_and_split(my_output)
381378
for line in line_array:
382379
linesplit = line.split()
383380
result.append(

jupyterlab_git/tests/test_detailed_log.py

Lines changed: 30 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,26 +17,27 @@ async def test_detailed_log():
1717
# Given
1818
process_output = [
1919
"f29660a (HEAD, origin/feature) Commit message",
20-
"10 3 notebook_without_spaces.ipynb",
21-
"11 4 Notebook with spaces.ipynb",
22-
"12 5 path/notebook_without_spaces.ipynb",
23-
"13 6 path/Notebook with spaces.ipynb",
24-
" notebook_without_spaces.ipynb | 13 ++++++++---",
25-
" Notebook with spaces.ipynb | 15 +++++++++----",
26-
" path/notebook_without_spaces.ipynb | 17 ++++++++++-----",
27-
" path/Notebook with spaces.ipynb | 19 +++++++++++------",
28-
" 4 files changed, 46 insertions(+), 18 deletions(-)",
20+
"10\t3\tnotebook_without_spaces.ipynb",
21+
"11\t4\tNotebook with spaces.ipynb",
22+
"12\t5\tpath/notebook_without_spaces.ipynb",
23+
"13\t6\tpath/Notebook with spaces.ipynb",
24+
"14\t1\tpath/Notebook with λ.ipynb",
25+
"0\t0\t",
26+
"folder1/file with spaces and λ.py",
27+
"folder2/file with spaces.py"
2928
]
30-
mock_execute.return_value = tornado.gen.maybe_future(
31-
(0, "\n".join(process_output), "")
29+
30+
31+
mock_execute._mock_return_value = tornado.gen.maybe_future(
32+
(0, "\x00".join(process_output), "")
3233
)
3334

3435
expected_response = {
3536
"code": 0,
36-
"modified_file_note": " 4 files changed, 46 insertions(+), 18 deletions(-)",
37-
"modified_files_count": "4",
38-
"number_of_insertions": "46",
39-
"number_of_deletions": "18",
37+
"modified_file_note": "6 files changed, 60 insertions(+), 19 deletions(-)",
38+
"modified_files_count": "6",
39+
"number_of_insertions": "60",
40+
"number_of_deletions": "19",
4041
"modified_files": [
4142
{
4243
"modified_file_path": "notebook_without_spaces.ipynb",
@@ -62,11 +63,23 @@ async def test_detailed_log():
6263
"insertion": "13",
6364
"deletion": "6",
6465
},
66+
{
67+
"modified_file_path": "path/Notebook with λ.ipynb",
68+
"modified_file_name": "Notebook with λ.ipynb",
69+
"insertion": "14",
70+
"deletion": "1",
71+
},
72+
{
73+
"modified_file_path": "folder2/file with spaces.py",
74+
"modified_file_name": "folder1/file with spaces and λ.py => folder2/file with spaces.py",
75+
"insertion": "0",
76+
"deletion": "0",
77+
},
6578
],
6679
}
6780

6881
# When
69-
actual_response = (await
82+
actual_response = (await
7083
Git(FakeContentManager("/bin"))
7184
.detailed_log(
7285
selected_hash="f29660a2472e24164906af8653babeb48e4bf2ab",
@@ -80,9 +93,9 @@ async def test_detailed_log():
8093
"git",
8194
"log",
8295
"-1",
83-
"--stat",
8496
"--numstat",
8597
"--oneline",
98+
"-z",
8699
"f29660a2472e24164906af8653babeb48e4bf2ab",
87100
],
88101
cwd=os.path.join("/bin", "test_curr_path"),

jupyterlab_git/tests/test_diff.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ async def test_changed_files_single_commit():
2424
with patch("jupyterlab_git.git.execute") as mock_execute:
2525
# Given
2626
mock_execute.return_value = tornado.gen.maybe_future(
27-
(0, "file1.ipynb\nfile2.py", "")
27+
(0, "file1.ipynb\x00file2.py", "")
2828
)
2929

3030
# When
@@ -39,6 +39,7 @@ async def test_changed_files_single_commit():
3939
"diff",
4040
"64950a634cd11d1a01ddfedaeffed67b531cb11e^!",
4141
"--name-only",
42+
"-z",
4243
],
4344
cwd="/bin",
4445
)
@@ -50,7 +51,7 @@ async def test_changed_files_working_tree():
5051
with patch("jupyterlab_git.git.execute") as mock_execute:
5152
# Given
5253
mock_execute.return_value = tornado.gen.maybe_future(
53-
(0, "file1.ipynb\nfile2.py", "")
54+
(0, "file1.ipynb\x00file2.py", "")
5455
)
5556

5657
# When
@@ -60,7 +61,7 @@ async def test_changed_files_working_tree():
6061

6162
# Then
6263
mock_execute.assert_called_once_with(
63-
["git", "diff", "HEAD", "--name-only"], cwd="/bin"
64+
["git", "diff", "HEAD", "--name-only", "-z"], cwd="/bin"
6465
)
6566
assert {"code": 0, "files": ["file1.ipynb", "file2.py"]} == actual_response
6667

@@ -70,7 +71,7 @@ async def test_changed_files_index():
7071
with patch("jupyterlab_git.git.execute") as mock_execute:
7172
# Given
7273
mock_execute.return_value = tornado.gen.maybe_future(
73-
(0, "file1.ipynb\nfile2.py", "")
74+
(0, "file1.ipynb\x00file2.py", "")
7475
)
7576

7677
# When
@@ -80,7 +81,7 @@ async def test_changed_files_index():
8081

8182
# Then
8283
mock_execute.assert_called_once_with(
83-
["git", "diff", "--staged", "HEAD", "--name-only"], cwd="/bin"
84+
["git", "diff", "--staged", "HEAD", "--name-only", "-z"], cwd="/bin"
8485
)
8586
assert {"code": 0, "files": ["file1.ipynb", "file2.py"]} == actual_response
8687

@@ -90,7 +91,7 @@ async def test_changed_files_two_commits():
9091
with patch("jupyterlab_git.git.execute") as mock_execute:
9192
# Given
9293
mock_execute.return_value = tornado.gen.maybe_future(
93-
(0, "file1.ipynb\nfile2.py", "")
94+
(0, "file1.ipynb\x00file2.py", "")
9495
)
9596

9697
# When
@@ -100,7 +101,7 @@ async def test_changed_files_two_commits():
100101

101102
# Then
102103
mock_execute.assert_called_once_with(
103-
["git", "diff", "HEAD", "origin/HEAD", "--name-only"], cwd="/bin"
104+
["git", "diff", "HEAD", "origin/HEAD", "--name-only", "-z"], cwd="/bin"
104105
)
105106
assert {"code": 0, "files": ["file1.ipynb", "file2.py"]} == actual_response
106107

@@ -118,6 +119,6 @@ async def test_changed_files_git_diff_error():
118119

119120
# Then
120121
mock_execute.assert_called_once_with(
121-
["git", "diff", "HEAD", "origin/HEAD", "--name-only"], cwd="/bin"
122+
["git", "diff", "HEAD", "origin/HEAD", "--name-only", "-z"], cwd="/bin"
122123
)
123124
assert {"code": 128, "message": "error message"} == actual_response

0 commit comments

Comments
 (0)