Skip to content

Commit f35465a

Browse files
eyusupovfcollonval
andauthored
Add option to ask user identity on every commit (#1251)
* Add option to ask user identity on every commit * Override author * Set author in config when not set even with prompt on every commit * Fix test * Apply suggestions from code review Co-authored-by: Frédéric Collonval <fcollonval@users.noreply.github.com> * Cleanup the code * Fix formatting * Improve author form layout * Fix linter --------- Co-authored-by: Frédéric Collonval <fcollonval@users.noreply.github.com> Co-authored-by: Frédéric Collonval <fcollonval@gmail.com>
1 parent c941ee9 commit f35465a

File tree

8 files changed

+133
-39
lines changed

8 files changed

+133
-39
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ Once installed, extension behavior can be modified via the following settings wh
103103
- **displayStatus**: display Git extension status updates in the JupyterLab status bar. If `true`, the extension displays status updates in the JupyterLab status bar, such as when pulling and pushing changes, switching branches, and polling for changes. Depending on the level of extension activity, some users may find the status updates distracting. In which case, setting this to `false` should reduce visual noise.
104104
- **doubleClickDiff**: double click a file in the Git extension panel to open a diff of the file instead of opening the file for editing.
105105
- **historyCount**: number of commits shown in the history log, beginning with the most recent. Displaying a larger number of commits can lead to performance degradation, so use caution when modifying this setting.
106+
- **promptUserIdentity**: Whether to prompt for user name and email on every commit.
106107
- **refreshIfHidden**: whether to refresh even if the Git tab is hidden; default to `false` (i.e. refresh is turned off if the Git tab is hidden).
107108
- **refreshInterval**: number of milliseconds between polling the file system for changes. In order to ensure that the UI correctly displays the current repository status, the extension must poll the file system for changes. Longer polling times increase the likelihood that the UI does not reflect the current status; however, longer polling times also incur less performance overhead.
108109
- **simpleStaging**: enable a simplified concept of staging. When this setting is `true`, all files with changes are automatically staged. When we develop in JupyterLab, we often only care about what files have changed (in the broadest sense) and don't need to distinguish between "tracked" and "untracked" files. Accordingly, this setting allows us to simplify the visual presentation of changes, which is especially useful for those less acquainted with Git.

jupyterlab_git/git.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1156,13 +1156,15 @@ async def merge(self, branch: str, path: str) -> dict:
11561156
return {"code": code, "command": " ".join(cmd), "message": error}
11571157
return {"code": code, "message": output.strip()}
11581158

1159-
async def commit(self, commit_msg, amend, path):
1159+
async def commit(self, commit_msg, amend, path, author=None):
11601160
"""
11611161
Execute git commit <filename> command & return the result.
11621162
11631163
If the amend argument is true, amend the commit instead of creating a new one.
11641164
"""
11651165
cmd = ["git", "commit"]
1166+
if author:
1167+
cmd.extend(["--author", author])
11661168
if amend:
11671169
cmd.extend(["--amend", "--no-edit"])
11681170
else:

jupyterlab_git/handlers.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -561,7 +561,10 @@ async def post(self, path: str = ""):
561561
data = self.get_json_body()
562562
commit_msg = data["commit_msg"]
563563
amend = data.get("amend", False)
564-
body = await self.git.commit(commit_msg, amend, self.url2localpath(path))
564+
author = data.get("author")
565+
body = await self.git.commit(
566+
commit_msg, amend, self.url2localpath(path), author
567+
)
565568

566569
if body["code"] != 0:
567570
self.set_status(500)

schema/plugin.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@
6565
"description": "Whether to trigger or not a push for each commit.",
6666
"default": false
6767
},
68+
"promptUserIdentity": {
69+
"type": "boolean",
70+
"title": "Prompt user identity on commit",
71+
"description": "Whether to prompt for user name and email for each commit.",
72+
"default": false
73+
},
6874
"openFilesBehindWarning": {
6975
"type": "boolean",
7076
"title": "Open files behind warning",

src/__tests__/test-components/GitPanel.spec.tsx

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,15 @@ const mockedResponses: {
4040
* @private
4141
* @returns mock settings
4242
*/
43-
function MockSettings(commitAndPush = true) {
43+
function MockSettings(commitAndPush = true, promptUserIdentity = false) {
4444
return {
4545
changed: {
4646
connect: () => true,
4747
disconnect: () => true
4848
},
4949
composite: {
50-
commitAndPush
50+
commitAndPush,
51+
promptUserIdentity
5152
}
5253
};
5354
}
@@ -161,7 +162,9 @@ describe('GitPanel', () => {
161162
expect(configSpy).toHaveBeenCalledTimes(1);
162163
expect(commitSpy).toHaveBeenCalledTimes(1);
163164
expect(commitSpy).toHaveBeenCalledWith(
164-
commitSummary + '\n\n' + commitDescription + '\n'
165+
commitSummary + '\n\n' + commitDescription + '\n',
166+
false,
167+
null
165168
);
166169

167170
// Only erase commit message upon success
@@ -175,17 +178,38 @@ describe('GitPanel', () => {
175178
expect(commitSpy).not.toHaveBeenCalled();
176179
});
177180

181+
it('should prompt for user identity if explicitly configured', async () => {
182+
configSpy.mockResolvedValue({ options: commitUser });
183+
184+
props.settings = MockSettings(false, true) as any;
185+
const panelWrapper = shallow<GitPanel>(<GitPanel {...props} />);
186+
panel = panelWrapper.instance();
187+
188+
mockUtils.showDialog.mockResolvedValue(dialogValue);
189+
190+
panel.setState({ commitSummary });
191+
192+
await panel.commitFiles();
193+
expect(configSpy).toHaveBeenCalledTimes(1);
194+
expect(configSpy.mock.calls[0]).toHaveLength(0);
195+
196+
const author = `${commitUser['user.name']} <${commitUser['user.email']}>`;
197+
expect(commitSpy).toHaveBeenCalledTimes(1);
198+
expect(commitSpy).toHaveBeenCalledWith(commitSummary, false, author);
199+
});
200+
178201
it('should prompt for user identity if user.name is not set', async () => {
179202
configSpy.mockImplementation(mockConfigImplementation('user.email'));
180203
mockUtils.showDialog.mockResolvedValue(dialogValue);
181204

182205
panel.setState({ commitSummary });
206+
183207
await panel.commitFiles();
184208
expect(configSpy).toHaveBeenCalledTimes(2);
185209
expect(configSpy.mock.calls[0]).toHaveLength(0);
186210
expect(configSpy.mock.calls[1]).toEqual([commitUser]);
187211
expect(commitSpy).toHaveBeenCalledTimes(1);
188-
expect(commitSpy).toHaveBeenCalledWith(commitSummary);
212+
expect(commitSpy).toHaveBeenCalledWith(commitSummary, false, null);
189213
});
190214

191215
it('should prompt for user identity if user.email is not set', async () => {
@@ -198,7 +222,7 @@ describe('GitPanel', () => {
198222
expect(configSpy.mock.calls[0]).toHaveLength(0);
199223
expect(configSpy.mock.calls[1]).toEqual([commitUser]);
200224
expect(commitSpy).toHaveBeenCalledTimes(1);
201-
expect(commitSpy).toHaveBeenCalledWith(commitSummary);
225+
expect(commitSpy).toHaveBeenCalledWith(commitSummary, false, null);
202226
});
203227

204228
it('should not commit if no user identity is set and the user rejects the dialog', async () => {

src/components/GitPanel.tsx

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -770,17 +770,17 @@ export class GitPanel extends React.Component<IGitPanelProps, IGitPanelState> {
770770
};
771771

772772
try {
773-
await this._hasIdentity(this.props.model.pathRepository);
773+
const author = await this._hasIdentity(this.props.model.pathRepository);
774774

775775
this.props.logger.log({
776776
level: Level.RUNNING,
777777
message: this.props.trans.__('Committing changes...')
778778
});
779779

780780
if (this.state.commitAmend) {
781-
await this.props.model.commit(null, true);
781+
await this.props.model.commit(null, true, author);
782782
} else {
783-
await this.props.model.commit(message);
783+
await this.props.model.commit(message, false, author);
784784
}
785785

786786
this.props.logger.log({
@@ -807,19 +807,38 @@ export class GitPanel extends React.Component<IGitPanelProps, IGitPanelState> {
807807
*
808808
* @param path - repository path
809809
*/
810-
private async _hasIdentity(path: string): Promise<void> {
811-
// If the repository path changes, check the user identity
812-
if (path !== this._previousRepoPath) {
810+
private async _hasIdentity(path: string): Promise<string | null> {
811+
// If the repository path changes or explicitly configured, check the user identity
812+
if (
813+
path !== this._previousRepoPath ||
814+
this.props.settings.composite['promptUserIdentity']
815+
) {
813816
try {
814-
const data: JSONObject = (await this.props.model.config()) as any;
815-
const options: JSONObject = data['options'] as JSONObject;
816-
const keys = Object.keys(options);
817+
let userOrEmailNotSet = false;
818+
let author: Git.IIdentity;
819+
let authorOverride: string | null = null;
820+
821+
if (this.props.model.lastAuthor === null) {
822+
const data: JSONObject = (await this.props.model.config()) as any;
823+
const options: JSONObject = data['options'] as JSONObject;
824+
825+
author = {
826+
name: (options['user.name'] as string) || '',
827+
email: (options['user.email'] as string) || ''
828+
};
829+
userOrEmailNotSet = !author.name || !author.email;
830+
} else {
831+
author = this.props.model.lastAuthor;
832+
}
817833

818-
// If the user name or e-mail is unknown, ask the user to set it
819-
if (keys.indexOf('user.name') < 0 || keys.indexOf('user.email') < 0) {
834+
// If explicitly configured or the user name or e-mail is unknown, ask the user to set it
835+
if (
836+
this.props.settings.composite['promptUserIdentity'] ||
837+
userOrEmailNotSet
838+
) {
820839
const result = await showDialog({
821840
title: this.props.trans.__('Who is committing?'),
822-
body: new GitAuthorForm()
841+
body: new GitAuthorForm({ author, trans: this.props.trans })
823842
});
824843

825844
if (!result.button.accept) {
@@ -828,13 +847,21 @@ export class GitPanel extends React.Component<IGitPanelProps, IGitPanelState> {
828847
);
829848
}
830849

831-
const { name, email } = result.value;
832-
await this.props.model.config({
833-
'user.name': name,
834-
'user.email': email
835-
});
850+
author = result.value;
851+
if (userOrEmailNotSet) {
852+
await this.props.model.config({
853+
'user.name': author.name,
854+
'user.email': author.email
855+
});
856+
}
857+
this.props.model.lastAuthor = author;
858+
859+
if (this.props.settings.composite['promptUserIdentity']) {
860+
authorOverride = `${author.name} <${author.email}>`;
861+
}
836862
}
837863
this._previousRepoPath = path;
864+
return authorOverride;
838865
} catch (error) {
839866
if (error instanceof Git.GitResponseError) {
840867
throw error;

src/model.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,18 @@ export class GitExtension implements IGitExtension {
198198
}
199199
}
200200

201+
/**
202+
* Last author
203+
*
204+
*/
205+
get lastAuthor(): Git.IIdentity | null {
206+
return this._lastAuthor;
207+
}
208+
209+
set lastAuthor(lastAuthor: Git.IIdentity) {
210+
this._lastAuthor = lastAuthor;
211+
}
212+
201213
/**
202214
* Git repository status
203215
*/
@@ -670,18 +682,24 @@ export class GitExtension implements IGitExtension {
670682
*
671683
* @param message - commit message
672684
* @param amend - whether this is an amend commit
685+
* @param author - override the commit author specified in config
673686
* @returns promise which resolves upon committing file changes
674687
*
675688
* @throws {Git.NotInRepository} If the current path is not a Git repository
676689
* @throws {Git.GitResponseError} If the server response is not ok
677690
* @throws {ServerConnection.NetworkError} If the request cannot be made
678691
*/
679-
async commit(message?: string, amend = false): Promise<void> {
692+
async commit(
693+
message?: string,
694+
amend = false,
695+
author?: string
696+
): Promise<void> {
680697
const path = await this._getPathRepository();
681698
await this._taskHandler.execute('git:commit:create', async () => {
682699
await requestAPI(URLExt.join(path, 'commit'), 'POST', {
683700
commit_msg: message,
684-
amend: amend
701+
amend: amend,
702+
author: author
685703
});
686704
});
687705
await this.refresh();
@@ -2118,6 +2136,7 @@ export class GitExtension implements IGitExtension {
21182136
private _selectedHistoryFile: Git.IStatusFile | null = null;
21192137
private _hasDirtyFiles = false;
21202138
private _credentialsRequired = false;
2139+
private _lastAuthor: Git.IIdentity | null = null;
21212140

21222141
// Configurable
21232142
private _statusForDirtyState: Git.Status[] = ['staged', 'partially-staged'];

src/widgets/AuthorBox.ts

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Dialog } from '@jupyterlab/apputils';
2+
import { TranslationBundle } from '@jupyterlab/translation';
23
import { Widget } from '@lumino/widgets';
34
import { Git } from '../tokens';
45

@@ -9,26 +10,37 @@ export class GitAuthorForm
910
extends Widget
1011
implements Dialog.IBodyWidget<Git.IIdentity>
1112
{
12-
constructor() {
13+
constructor({
14+
author,
15+
trans
16+
}: {
17+
author: Git.IIdentity;
18+
trans: TranslationBundle;
19+
}) {
1320
super();
14-
this.node.appendChild(this.createBody());
21+
this._populateForm(author, trans);
1522
}
1623

17-
private createBody(): HTMLElement {
18-
const node = document.createElement('div');
19-
const text = document.createElement('span');
20-
this._name = document.createElement('input');
21-
this._email = document.createElement('input');
24+
private _populateForm(
25+
author: Git.IIdentity,
26+
trans?: TranslationBundle
27+
): void {
28+
const nameLabel = document.createElement('label');
29+
nameLabel.textContent = trans.__('Committer name:');
30+
const emailLabel = document.createElement('label');
31+
emailLabel.textContent = trans.__('Committer email:');
2232

23-
node.className = 'jp-RedirectForm';
24-
text.textContent = 'Enter your name and email for commit';
33+
this._name = nameLabel.appendChild(document.createElement('input'));
34+
this._email = emailLabel.appendChild(document.createElement('input'));
2535
this._name.placeholder = 'Name';
36+
this._email.type = 'text';
2637
this._email.placeholder = 'Email';
38+
this._email.type = 'email';
39+
this._name.value = author.name;
40+
this._email.value = author.email;
2741

28-
node.appendChild(text);
29-
node.appendChild(this._name);
30-
node.appendChild(this._email);
31-
return node;
42+
this.node.appendChild(nameLabel);
43+
this.node.appendChild(emailLabel);
3244
}
3345

3446
/**

0 commit comments

Comments
 (0)