Skip to content

Commit 3221c3e

Browse files
Support downloading a git repository snapshot (#1177)
* updt git.py file to upload git repo with depth * setting a metaDataChecked boolean flag upon git cloning a repository * clone based on metaDataChecked flag * clone with metadata if flag unchecked * resolved PR comments with styling and .git file issues * refactor git.py and resolve css bugs * fix translation & starter test * clone the repo in the test * fix imports * fix build failures * . * fix pR build fixes * update var name to 'not_versioning' * fix naming issues * fix var name inconsistency * . * . * .' * Make versioning optional to be API backward compatible * Lint the code * Reorder `Git.clone` args Fix unit tests * Update jupyterlab_git/tests/test_clone.py Co-authored-by: Frédéric Collonval <fcollonval@users.noreply.github.com> * fix bugs * . * . * add abs path * . Co-authored-by: Frédéric Collonval <fcollonval@users.noreply.github.com>
1 parent 46d29f5 commit 3221c3e

File tree

9 files changed

+126
-20
lines changed

9 files changed

+126
-20
lines changed

jupyterlab_git/git.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import pathlib
77
import re
88
import shlex
9+
import shutil
910
import subprocess
1011
import traceback
1112
from typing import Dict, List, Optional
@@ -271,23 +272,31 @@ async def changed_files(self, path, base=None, remote=None, single_commit=None):
271272

272273
return response
273274

274-
async def clone(self, path, repo_url, auth=None):
275+
async def clone(self, path, repo_url, auth=None, versioning=True):
275276
"""
276277
Execute `git clone`.
277278
When no auth is provided, disables prompts for the password to avoid the terminal hanging.
278279
When auth is provided, await prompts for username/passwords and sends them
279280
:param path: the directory where the clone will be performed.
280281
:param repo_url: the URL of the repository to be cloned.
281282
:param auth: OPTIONAL dictionary with 'username' and 'password' fields
283+
:param versioning: OPTIONAL whether to clone or download a snapshot of the remote repository; default clone
282284
:return: response with status code and error message.
283285
"""
284286
env = os.environ.copy()
287+
cmd = ["git", "clone"]
288+
if not versioning:
289+
cmd.append("--depth=1")
290+
current_content = set(os.listdir(path))
291+
cmd.append(unquote(repo_url))
292+
285293
if auth:
286294
if auth.get("cache_credentials"):
287295
await self.ensure_credential_helper(path)
288296
env["GIT_TERMINAL_PROMPT"] = "1"
297+
cmd.append("-q")
289298
code, output, error = await execute(
290-
["git", "clone", unquote(repo_url), "-q"],
299+
cmd,
291300
username=auth["username"],
292301
password=auth["password"],
293302
cwd=path,
@@ -296,11 +305,16 @@ async def clone(self, path, repo_url, auth=None):
296305
else:
297306
env["GIT_TERMINAL_PROMPT"] = "0"
298307
code, output, error = await execute(
299-
["git", "clone", unquote(repo_url)],
308+
cmd,
300309
cwd=path,
301310
env=env,
302311
)
303312

313+
if not versioning:
314+
new_content = set(os.listdir(path))
315+
directory = (new_content - current_content).pop()
316+
shutil.rmtree(f"{path}/{directory}/.git")
317+
304318
response = {"code": code, "message": output.strip()}
305319

306320
if code != 0:

jupyterlab_git/handlers.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ async def post(self, path: str = ""):
8888
self.url2localpath(path),
8989
data["clone_url"],
9090
data.get("auth", None),
91+
data["versioning"],
9192
)
9293

9394
if response["code"] != 0:

jupyterlab_git/tests/test_clone.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,38 @@ async def test_git_clone_success():
3232
assert {"code": 0, "message": output} == actual_response
3333

3434

35+
@pytest.mark.asyncio
36+
async def test_git_download_success(tmp_path):
37+
with patch("os.environ", {"TEST": "test"}):
38+
with patch("jupyterlab_git.git.execute") as mock_execute:
39+
# Given
40+
output = "output"
41+
git_folder = tmp_path / "dummy-repo" / ".git"
42+
43+
def create_fake_git_repo(*args, **kwargs):
44+
git_folder.mkdir(parents=True)
45+
return maybe_future((0, output, "error"))
46+
47+
mock_execute.side_effect = create_fake_git_repo
48+
49+
# When
50+
await Git().clone(
51+
path=str(tmp_path), repo_url="ghjkhjkl", auth=None, versioning=False
52+
)
53+
54+
# Then
55+
mock_execute.assert_called_once_with(
56+
["git", "clone", "--depth=1", "ghjkhjkl"], # to be update
57+
cwd=str(tmp_path), # to be udpated
58+
env={"TEST": "test", "GIT_TERMINAL_PROMPT": "0"},
59+
)
60+
61+
# Check the repository folder has been created
62+
assert git_folder.parent.exists()
63+
# Check the `.git` folder has been removed.
64+
assert not git_folder.exists()
65+
66+
3567
@pytest.mark.asyncio
3668
async def test_git_clone_failure_from_git():
3769
"""

src/cloneCommand.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export const gitCloneCommandPlugin: JupyterFrontEndPlugin<void> = {
4646
]
4747
});
4848

49-
if (result.button.accept && result.value) {
49+
if (result.button.accept && result.value.url) {
5050
logger.log({
5151
level: Level.RUNNING,
5252
message: trans.__('Cloning…')
@@ -56,7 +56,11 @@ export const gitCloneCommandPlugin: JupyterFrontEndPlugin<void> = {
5656
gitModel as GitExtension,
5757
Operation.Clone,
5858
trans,
59-
{ path: fileBrowserModel.path, url: result.value }
59+
{
60+
path: fileBrowserModel.path,
61+
url: result.value.url,
62+
versioning: result.value.versioning
63+
}
6064
);
6165
logger.log({
6266
message: trans.__('Successfully cloned'),

src/commandsAndMenu.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ export interface IGitCloneArgs {
6060
* Git repository url
6161
*/
6262
url: string;
63+
/**
64+
* Whether to activate git versioning in the clone or not.
65+
* If false, this will remove the .git folder after cloning.
66+
*/
67+
versioning?: boolean;
6368
}
6469

6570
/**
@@ -1538,8 +1543,13 @@ export async function showGitOperationDialog<T>(
15381543
switch (operation) {
15391544
case Operation.Clone:
15401545
// eslint-disable-next-line no-case-declarations
1541-
const { path, url } = args as any as IGitCloneArgs;
1542-
result = await model.clone(path, url, authentication);
1546+
const { path, url, versioning } = args as any as IGitCloneArgs;
1547+
result = await model.clone(
1548+
path,
1549+
url,
1550+
authentication,
1551+
versioning ?? true
1552+
);
15431553
break;
15441554
case Operation.Pull:
15451555
result = await model.pull(authentication);

src/model.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,7 @@ export class GitExtension implements IGitExtension {
619619
* @param path - local path into which the repository will be cloned
620620
* @param url - Git repository URL
621621
* @param auth - remote repository authentication information
622+
* @param versioning - boolean flag of Git metadata (default true)
622623
* @returns promise which resolves upon cloning a repository
623624
*
624625
* @throws {Git.GitResponseError} If the server response is not ok
@@ -627,7 +628,8 @@ export class GitExtension implements IGitExtension {
627628
async clone(
628629
path: string,
629630
url: string,
630-
auth?: Git.IAuth
631+
auth?: Git.IAuth,
632+
versioning = true
631633
): Promise<Git.IResultWithMessage> {
632634
return await this._taskHandler.execute<Git.IResultWithMessage>(
633635
'git:clone',
@@ -637,6 +639,7 @@ export class GitExtension implements IGitExtension {
637639
'POST',
638640
{
639641
clone_url: url,
642+
versioning: versioning,
640643
auth: auth as any
641644
}
642645
);

src/tokens.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ export interface IGitExtension extends IDisposable {
232232
* @param path - local path into which the repository will be cloned
233233
* @param url - Git repository URL
234234
* @param auth - remote repository authentication information
235+
* @param versioning - Whether to clone or download the Git repository
235236
* @returns promise which resolves upon cloning a repository
236237
*
237238
* @throws {Git.GitResponseError} If the server response is not ok
@@ -240,7 +241,8 @@ export interface IGitExtension extends IDisposable {
240241
clone(
241242
path: string,
242243
url: string,
243-
auth?: Git.IAuth
244+
auth?: Git.IAuth,
245+
versioning?: boolean
244246
): Promise<Git.IResultWithMessage>;
245247

246248
/**

src/widgets/GitCloneForm.ts

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,23 +16,59 @@ export class GitCloneForm extends Widget {
1616
/**
1717
* Returns the input value.
1818
*/
19-
getValue(): string {
20-
return encodeURIComponent(this.node.querySelector('input').value.trim());
19+
getValue(): { url: string; versioning: boolean } {
20+
return {
21+
url: encodeURIComponent(
22+
(
23+
this.node.querySelector('#input-link') as HTMLInputElement
24+
).value.trim()
25+
),
26+
versioning: Boolean(
27+
encodeURIComponent(
28+
(this.node.querySelector('#checkbox') as HTMLInputElement).checked
29+
)
30+
)
31+
};
2132
}
2233

2334
private static createFormNode(trans: TranslationBundle): HTMLElement {
2435
const node = document.createElement('div');
25-
const label = document.createElement('label');
26-
const input = document.createElement('input');
27-
const text = document.createElement('span');
36+
const inputWrapper = document.createElement('div');
37+
const inputLinkLabel = document.createElement('label');
38+
const inputLink = document.createElement('input');
39+
const linkText = document.createElement('span');
40+
const checkboxWrapper = document.createElement('div');
41+
const checkboxLabel = document.createElement('label');
42+
const checkbox = document.createElement('input');
2843

29-
node.className = 'jp-RedirectForm';
30-
text.textContent = trans.__('Enter the Clone URI of the repository');
31-
input.placeholder = 'https://host.com/org/repo.git';
44+
node.className = 'jp-CredentialsBox';
45+
inputWrapper.className = 'jp-RedirectForm';
46+
checkboxWrapper.className = 'jp-CredentialsBox-wrapper';
47+
checkboxLabel.className = 'jp-CredentialsBox-label-checkbox';
48+
checkbox.id = 'checkbox';
49+
inputLink.id = 'input-link';
50+
51+
linkText.textContent = trans.__(
52+
'Enter the URI of the remote Git repository'
53+
);
54+
inputLink.placeholder = 'https://host.com/org/repo.git';
55+
checkboxLabel.textContent = trans.__('Download the repository');
56+
checkboxLabel.title = trans.__(
57+
'If checked, the remote repository default branch will be downloaded instead of cloned'
58+
);
59+
checkbox.setAttribute('type', 'checkbox');
60+
61+
inputLinkLabel.appendChild(linkText);
62+
inputLinkLabel.appendChild(inputLink);
63+
64+
inputWrapper.append(inputLinkLabel);
65+
66+
checkboxLabel.prepend(checkbox);
67+
checkboxWrapper.appendChild(checkboxLabel);
68+
69+
node.appendChild(inputWrapper);
70+
node.appendChild(checkboxWrapper);
3271

33-
label.appendChild(text);
34-
label.appendChild(input);
35-
node.appendChild(label);
3672
return node;
3773
}
3874
}

style/credentials-box.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,7 @@
1818
display: flex;
1919
align-items: center;
2020
}
21+
22+
.jp-CredentialsBox-wrapper {
23+
margin-top: 10px;
24+
}

0 commit comments

Comments
 (0)