Skip to content

Commit c1ffae2

Browse files
chrisjohn2306Chris Johnfcollonval
authored
Git tag feature (jupyterlab#713)
* Git Tag list feature changes * Improve checkout tag PR Use SVG icon Reduce code foot print Use new Alert component * Correct unit test * Explicitly dependent on `requests_unixsocket` for test * Correct toolbar unit test Co-authored-by: Chris John <[email protected]> Co-authored-by: Frederic Collonval <[email protected]>
1 parent e95e007 commit c1ffae2

File tree

11 files changed

+428
-22
lines changed

11 files changed

+428
-22
lines changed

Diff for: jupyterlab_git/git.py

+34-4
Original file line numberDiff line numberDiff line change
@@ -950,17 +950,15 @@ async def get_upstream_branch(self, current_path, branch_name):
950950
command, cwd=os.path.join(self.root_dir, current_path)
951951
)
952952
if code != 0:
953-
return {"code": code, "command": " ".join(cmd), "message": error}
953+
return {"code": code, "command": " ".join(command), "message": error}
954954

955955
remote_name = output.strip()
956956
remote_branch = rev_parse_output.strip().lstrip(remote_name+"/")
957957
return {"code": code, "remote_short_name": remote_name, "remote_branch": remote_branch}
958958

959-
960-
961959
async def _get_tag(self, current_path, commit_sha):
962960
"""Execute 'git describe commit_sha' to get
963-
nearest tag associated with lastest commit in branch.
961+
nearest tag associated with latest commit in branch.
964962
Reference : https://git-scm.com/docs/git-describe#git-describe-ltcommit-ishgt82308203
965963
"""
966964
command = ["git", "describe", "--tags", commit_sha]
@@ -1125,3 +1123,35 @@ async def version(self):
11251123
return version.group('version')
11261124

11271125
return None
1126+
1127+
async def tags(self, current_path):
1128+
"""List all tags of the git repository.
1129+
1130+
current_path: str
1131+
Git path repository
1132+
"""
1133+
command = ["git", "tag", "--list"]
1134+
code, output, error = await execute(command, cwd=os.path.join(self.root_dir, current_path))
1135+
if code != 0:
1136+
return {"code": code, "command": " ".join(command), "message": error}
1137+
tags = [tag for tag in output.split("\n") if len(tag) > 0]
1138+
return {"code": code, "tags": tags}
1139+
1140+
async def tag_checkout(self, current_path, tag):
1141+
"""Checkout the git repository at a given tag.
1142+
1143+
current_path: str
1144+
Git path repository
1145+
tag : str
1146+
Tag to checkout
1147+
"""
1148+
command = ["git", "checkout", "tags/" + tag]
1149+
code, _, error = await execute(command, cwd=os.path.join(self.root_dir, current_path))
1150+
if code == 0:
1151+
return {"code": code, "message": "Tag {} checked out".format(tag)}
1152+
else:
1153+
return {
1154+
"code": code,
1155+
"command": " ".join(command),
1156+
"message": error,
1157+
}

Diff for: jupyterlab_git/handlers.py

+34
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,38 @@ async def get(self):
561561
)
562562

563563

564+
class GitTagHandler(GitHandler):
565+
"""
566+
Handler for 'git tag '. Fetches list of all tags in current repository
567+
"""
568+
569+
@web.authenticated
570+
async def post(self):
571+
"""
572+
POST request handler, fetches all tags in current repository.
573+
"""
574+
current_path = self.get_json_body()["current_path"]
575+
result = await self.git.tags(current_path)
576+
self.finish(json.dumps(result))
577+
578+
579+
class GitTagCheckoutHandler(GitHandler):
580+
"""
581+
Handler for 'git tag checkout '. Checkout the tag version of repo
582+
"""
583+
584+
@web.authenticated
585+
async def post(self):
586+
"""
587+
POST request handler, checkout the tag version to a branch.
588+
"""
589+
data = self.get_json_body()
590+
current_path = data["current_path"]
591+
tag = data["tag_id"]
592+
result = await self.git.tag_checkout(current_path, tag)
593+
self.finish(json.dumps(result))
594+
595+
564596
def setup_handlers(web_app):
565597
"""
566598
Setups all of the git command handlers.
@@ -594,6 +626,8 @@ def setup_handlers(web_app):
594626
("/git/show_top_level", GitShowTopLevelHandler),
595627
("/git/status", GitStatusHandler),
596628
("/git/upstream", GitUpstreamHandler),
629+
("/git/tags", GitTagHandler),
630+
("/git/tag_checkout", GitTagCheckoutHandler)
597631
]
598632

599633
# add the baseurl to our paths

Diff for: jupyterlab_git/tests/test_tag.py

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import os
2+
from unittest.mock import Mock, call, patch
3+
4+
import pytest
5+
import tornado
6+
7+
from jupyterlab_git.git import Git
8+
9+
from .testutils import FakeContentManager, ServerTest, maybe_future
10+
11+
@pytest.mark.asyncio
12+
async def test_git_tag_success():
13+
with patch("jupyterlab_git.git.execute") as mock_execute:
14+
tag = "1.0.0"
15+
# Given
16+
mock_execute.return_value = maybe_future((0, tag, ""))
17+
18+
# When
19+
actual_response = await Git(FakeContentManager("/bin")).tags("test_curr_path")
20+
21+
# Then
22+
mock_execute.assert_called_once_with(
23+
["git", "tag", "--list"],
24+
cwd=os.path.join("/bin", "test_curr_path"),
25+
)
26+
27+
assert {"code": 0, "tags": [tag]} == actual_response
28+
29+
@pytest.mark.asyncio
30+
async def test_git_tag_checkout_success():
31+
with patch("os.environ", {"TEST": "test"}):
32+
with patch("jupyterlab_git.git.execute") as mock_execute:
33+
tag = "mock_tag"
34+
# Given
35+
mock_execute.return_value = maybe_future((0, "", ""))
36+
37+
# When
38+
actual_response = await Git(FakeContentManager("/bin")).tag_checkout("test_curr_path", "mock_tag")
39+
40+
# Then
41+
mock_execute.assert_called_once_with(
42+
["git", "checkout", "tags/{}".format(tag)],
43+
cwd=os.path.join("/bin", "test_curr_path"),
44+
)
45+
46+
assert {"code": 0, "message": "Tag {} checked out".format(tag)} == actual_response

Diff for: setup.py

+1
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ def runPackLabextension():
7878
],
7979
extras_require = {
8080
'test': [
81+
'requests_unixsocket',
8182
'pytest',
8283
'pytest-asyncio',
8384
'jupyterlab~=2.0',

Diff for: src/components/Toolbar.tsx

+60-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { showDialog } from '@jupyterlab/apputils';
12
import { PathExt } from '@jupyterlab/coreutils';
23
import {
34
caretDownIcon,
@@ -7,7 +8,13 @@ import {
78
import * as React from 'react';
89
import { classes } from 'typestyle';
910
import { CommandIDs } from '../commandsAndMenu';
10-
import { branchIcon, desktopIcon, pullIcon, pushIcon } from '../style/icons';
11+
import {
12+
branchIcon,
13+
desktopIcon,
14+
pullIcon,
15+
pushIcon,
16+
tagIcon
17+
} from '../style/icons';
1118
import {
1219
spacer,
1320
toolbarButtonClass,
@@ -21,8 +28,9 @@ import {
2128
toolbarMenuWrapperClass,
2229
toolbarNavClass
2330
} from '../style/Toolbar';
24-
import { IGitExtension, ILogMessage } from '../tokens';
31+
import { IGitExtension, ILogMessage, Git } from '../tokens';
2532
import { sleep } from '../utils';
33+
import { GitTagDialog } from '../widgets/TagList';
2634
import { ActionButton } from './ActionButton';
2735
import { Alert } from './Alert';
2836
import { BranchMenu } from './BranchMenu';
@@ -175,6 +183,12 @@ export class Toolbar extends React.Component<IToolbarProps, IToolbarState> {
175183
onClick={this._onPushClick}
176184
title={'Push committed changes'}
177185
/>
186+
<ActionButton
187+
className={toolbarButtonClass}
188+
icon={tagIcon}
189+
onClick={this._onTagClick}
190+
title={'Checkout a tag'}
191+
/>
178192
<ActionButton
179193
className={toolbarButtonClass}
180194
icon={refreshIcon}
@@ -425,4 +439,48 @@ export class Toolbar extends React.Component<IToolbarProps, IToolbarState> {
425439
alert: false
426440
});
427441
};
442+
443+
/**
444+
* Callback invoked upon clicking a button to view tags.
445+
*
446+
* @param event - event object
447+
*/
448+
private _onTagClick = async (): Promise<void> => {
449+
const result = await showDialog({
450+
title: 'Checkout tag',
451+
body: new GitTagDialog(this.props.model)
452+
});
453+
if (result.button.accept) {
454+
this._log({
455+
severity: 'info',
456+
message: `Switching to ${result.value}...`
457+
});
458+
this._suspend(true);
459+
460+
let response: Git.ICheckoutResult;
461+
try {
462+
response = await this.props.model.checkoutTag(result.value);
463+
} catch (error) {
464+
response = {
465+
code: -1,
466+
message: error.message || error
467+
};
468+
} finally {
469+
this._suspend(false);
470+
}
471+
472+
if (response.code !== 0) {
473+
console.error(response.message);
474+
this._log({
475+
severity: 'error',
476+
message: `Fail to checkout tag ${result.value}`
477+
});
478+
} else {
479+
this._log({
480+
severity: 'success',
481+
message: `Switched to ${result.value}`
482+
});
483+
}
484+
}
485+
};
428486
}

Diff for: src/model.ts

+75
Original file line numberDiff line numberDiff line change
@@ -1019,6 +1019,81 @@ export class GitExtension implements IGitExtension {
10191019
return data;
10201020
}
10211021

1022+
/**
1023+
* Retrieve the list of tags in the repository.
1024+
*
1025+
* @returns promise which resolves upon retrieving the tag list
1026+
*/
1027+
async tags(): Promise<Git.ITagResult> {
1028+
let response;
1029+
1030+
await this.ready;
1031+
1032+
const path = this.pathRepository;
1033+
if (path === null) {
1034+
response = {
1035+
code: -1,
1036+
message: 'Not in a Git repository.'
1037+
};
1038+
return Promise.resolve(response);
1039+
}
1040+
1041+
const tid = this._addTask('git:tag:list');
1042+
try {
1043+
response = await httpGitRequest('/git/tags', 'POST', {
1044+
current_path: path
1045+
});
1046+
} catch (err) {
1047+
throw new ServerConnection.NetworkError(err);
1048+
} finally {
1049+
this._removeTask(tid);
1050+
}
1051+
1052+
const data = await response.json();
1053+
if (!response.ok) {
1054+
throw new ServerConnection.ResponseError(response, data.message);
1055+
}
1056+
return data;
1057+
}
1058+
1059+
/**
1060+
* Checkout the specified tag version
1061+
*
1062+
* @param tag - selected tag version
1063+
* @returns promise which resolves upon checking out the tag version of the repository
1064+
*/
1065+
async checkoutTag(tag: string): Promise<Git.ICheckoutResult> {
1066+
let response;
1067+
1068+
await this.ready;
1069+
1070+
const path = this.pathRepository;
1071+
if (path === null) {
1072+
response = {
1073+
code: -1,
1074+
message: 'Not in a Git repository.'
1075+
};
1076+
return Promise.resolve(response);
1077+
}
1078+
1079+
const tid = this._addTask('git:tag:checkout');
1080+
try {
1081+
response = await httpGitRequest('/git/tag_checkout', 'POST', {
1082+
current_path: path,
1083+
tag_id: tag
1084+
});
1085+
} catch (err) {
1086+
throw new ServerConnection.NetworkError(err);
1087+
} finally {
1088+
this._removeTask(tid);
1089+
}
1090+
const data = await response.json();
1091+
if (!response.ok) {
1092+
throw new ServerConnection.ResponseError(response, data.message);
1093+
}
1094+
return data;
1095+
}
1096+
10221097
/**
10231098
* Add a file to the current marker object.
10241099
*

Diff for: src/style/icons.ts

+13-8
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,9 @@ import pullSvg from '../../style/icons/pull.svg';
1515
import pushSvg from '../../style/icons/push.svg';
1616
import removeSvg from '../../style/icons/remove.svg';
1717
import rewindSvg from '../../style/icons/rewind.svg';
18+
import tagSvg from '../../style/icons/tag.svg';
1819

1920
export const gitIcon = new LabIcon({ name: 'git', svgstr: gitSvg });
20-
export const deletionsMadeIcon = new LabIcon({
21-
name: 'git:deletions',
22-
svgstr: deletionsMadeSvg
23-
});
24-
export const insertionsMadeIcon = new LabIcon({
25-
name: 'git:insertions',
26-
svgstr: insertionsMadeSvg
27-
});
2821
export const addIcon = new LabIcon({
2922
name: 'git:add',
3023
svgstr: addSvg
@@ -37,6 +30,10 @@ export const cloneIcon = new LabIcon({
3730
name: 'git:clone',
3831
svgstr: cloneSvg
3932
});
33+
export const deletionsMadeIcon = new LabIcon({
34+
name: 'git:deletions',
35+
svgstr: deletionsMadeSvg
36+
});
4037
export const desktopIcon = new LabIcon({
4138
name: 'git:desktop',
4239
svgstr: desktopSvg
@@ -49,6 +46,10 @@ export const discardIcon = new LabIcon({
4946
name: 'git:discard',
5047
svgstr: discardSvg
5148
});
49+
export const insertionsMadeIcon = new LabIcon({
50+
name: 'git:insertions',
51+
svgstr: insertionsMadeSvg
52+
});
5253
export const openIcon = new LabIcon({
5354
name: 'git:open-file',
5455
svgstr: openSvg
@@ -69,3 +70,7 @@ export const rewindIcon = new LabIcon({
6970
name: 'git:rewind',
7071
svgstr: rewindSvg
7172
});
73+
export const tagIcon = new LabIcon({
74+
name: 'git:tag',
75+
svgstr: tagSvg
76+
});

0 commit comments

Comments
 (0)