diff --git a/extensions/git/extension.webpack.config.js b/extensions/git/extension.webpack.config.js index 5efa2052e88e5..ee6203af47e45 100644 --- a/extensions/git/extension.webpack.config.js +++ b/extensions/git/extension.webpack.config.js @@ -13,6 +13,7 @@ module.exports = withDefaults({ context: __dirname, entry: { main: './src/main.ts', - ['askpass-main']: './src/askpass-main.ts' + ['askpass-main']: './src/askpass-main.ts', + ['git-editor-main']: './src/gitEditor/main.ts' } }); diff --git a/extensions/git/package.json b/extensions/git/package.json index de341a095ee61..1c6ca2f4aff84 100644 --- a/extensions/git/package.json +++ b/extensions/git/package.json @@ -12,7 +12,9 @@ "enabledApiProposals": [ "diffCommand", "contribViewsWelcome", + "resolvers", "scmActionButton", + "scmInput", "scmSelectedProvider", "scmValidation", "timeline" @@ -211,83 +213,99 @@ "command": "git.commit", "title": "%command.commit%", "category": "Git", - "icon": "$(check)" + "icon": "$(check)", + "enablement": "!commitInProgress" }, { "command": "git.commitStaged", "title": "%command.commitStaged%", - "category": "Git" + "category": "Git", + "enablement": "!commitInProgress" }, { "command": "git.commitEmpty", "title": "%command.commitEmpty%", - "category": "Git" + "category": "Git", + "enablement": "!commitInProgress" }, { "command": "git.commitStagedSigned", "title": "%command.commitStagedSigned%", - "category": "Git" + "category": "Git", + "enablement": "!commitInProgress" }, { "command": "git.commitStagedAmend", "title": "%command.commitStagedAmend%", - "category": "Git" + "category": "Git", + "enablement": "!commitInProgress" }, { "command": "git.commitAll", "title": "%command.commitAll%", - "category": "Git" + "category": "Git", + "enablement": "!commitInProgress" }, { "command": "git.commitAllSigned", "title": "%command.commitAllSigned%", - "category": "Git" + "category": "Git", + "enablement": "!commitInProgress" }, { "command": "git.commitAllAmend", "title": "%command.commitAllAmend%", - "category": "Git" + "category": "Git", + "enablement": "!commitInProgress" }, { "command": "git.commitNoVerify", "title": "%command.commitNoVerify%", "category": "Git", - "icon": "$(check)" + "icon": "$(check)", + "enablement": "!commitInProgress" }, { "command": "git.commitStagedNoVerify", "title": "%command.commitStagedNoVerify%", - "category": "Git" + "category": "Git", + "enablement": "!commitInProgress" }, { "command": "git.commitEmptyNoVerify", "title": "%command.commitEmptyNoVerify%", - "category": "Git" + "category": "Git", + "enablement": "!commitInProgress" }, { "command": "git.commitStagedSignedNoVerify", "title": "%command.commitStagedSignedNoVerify%", - "category": "Git" + "category": "Git", + "enablement": "!commitInProgress" }, { "command": "git.commitStagedAmendNoVerify", "title": "%command.commitStagedAmendNoVerify%", - "category": "Git" + "category": "Git", + "enablement": "!commitInProgress" }, { "command": "git.commitAllNoVerify", "title": "%command.commitAllNoVerify%", - "category": "Git" + "category": "Git", + "enablement": "!commitInProgress" }, { "command": "git.commitAllSignedNoVerify", "title": "%command.commitAllSignedNoVerify%", - "category": "Git" + "category": "Git", + "enablement": "!commitInProgress" }, { "command": "git.commitAllAmendNoVerify", "title": "%command.commitAllAmendNoVerify%", - "category": "Git" + "category": "Git", + "enablement": "!commitInProgress" }, { "command": "git.restoreCommitTemplate", @@ -1986,6 +2004,18 @@ "scope": "machine", "description": "%config.defaultCloneDirectory%" }, + "git.useEditorAsCommitInput": { + "type": "boolean", + "scope": "resource", + "description": "%config.useEditorAsCommitInput%", + "default": false + }, + "git.verboseCommit": { + "type": "boolean", + "scope": "resource", + "markdownDescription": "%config.verboseCommit%", + "default": false + }, "git.enableSmartCommit": { "type": "boolean", "scope": "resource", diff --git a/extensions/git/package.nls.json b/extensions/git/package.nls.json index ec6911305082f..d70493951b6c8 100644 --- a/extensions/git/package.nls.json +++ b/extensions/git/package.nls.json @@ -135,6 +135,8 @@ "config.ignoreLimitWarning": "Ignores the warning when there are too many changes in a repository.", "config.ignoreRebaseWarning": "Ignores the warning when it looks like the branch might have been rebased when pulling.", "config.defaultCloneDirectory": "The default location to clone a git repository.", + "config.useEditorAsCommitInput": "Use an editor to author the commit message.", + "config.verboseCommit": "Enable verbose output when `#git.useEditorAsCommitInput#` is enabled.", "config.enableSmartCommit": "Commit all changes when there are no staged changes.", "config.smartCommitChanges": "Control which changes are automatically staged by Smart Commit.", "config.smartCommitChanges.all": "Automatically stage all changes.", diff --git a/extensions/git/src/api/git.d.ts b/extensions/git/src/api/git.d.ts index 7ba58a0e6073a..e552f03f25d6d 100644 --- a/extensions/git/src/api/git.d.ts +++ b/extensions/git/src/api/git.d.ts @@ -137,6 +137,8 @@ export interface CommitOptions { empty?: boolean; noVerify?: boolean; requireUserConfig?: boolean; + useEditor?: boolean; + verbose?: boolean; } export interface FetchOptions { @@ -336,4 +338,5 @@ export const enum GitErrorCodes { PatchDoesNotApply = 'PatchDoesNotApply', NoPathFound = 'NoPathFound', UnknownPath = 'UnknownPath', + EmptyCommitMessage = 'EmptyCommitMessage' } diff --git a/extensions/git/src/askpass.ts b/extensions/git/src/askpass.ts index 81895a0e0d6dc..ffbd7e48a0e2b 100644 --- a/extensions/git/src/askpass.ts +++ b/extensions/git/src/askpass.ts @@ -6,9 +6,8 @@ import { window, InputBoxOptions, Uri, Disposable, workspace } from 'vscode'; import { IDisposable, EmptyDisposable, toDisposable } from './util'; import * as path from 'path'; -import { IIPCHandler, IIPCServer, createIPCServer } from './ipc/ipcServer'; +import { IIPCHandler, IIPCServer } from './ipc/ipcServer'; import { CredentialsProvider, Credentials } from './api/git'; -import { OutputChannelLogger } from './log'; export class Askpass implements IIPCHandler { @@ -16,16 +15,7 @@ export class Askpass implements IIPCHandler { private cache = new Map(); private credentialsProviders = new Set(); - static async create(outputChannelLogger: OutputChannelLogger, context?: string): Promise { - try { - return new Askpass(await createIPCServer(context)); - } catch (err) { - outputChannelLogger.logError(`Failed to create git askpass IPC: ${err}`); - return new Askpass(); - } - } - - private constructor(private ipc?: IIPCServer) { + constructor(private ipc?: IIPCServer) { if (ipc) { this.disposable = ipc.registerHandler('askpass', this); } diff --git a/extensions/git/src/commands.ts b/extensions/git/src/commands.ts index 6852639fcda4d..3597d62a52b13 100644 --- a/extensions/git/src/commands.ts +++ b/extensions/git/src/commands.ts @@ -1443,6 +1443,14 @@ export class CommandCenter { opts.signoff = true; } + if (config.get('useEditorAsCommitInput')) { + opts.useEditor = true; + + if (config.get('verboseCommit')) { + opts.verbose = true; + } + } + const smartCommitChanges = config.get<'all' | 'tracked'>('smartCommitChanges'); if ( @@ -1490,7 +1498,7 @@ export class CommandCenter { let message = await getCommitMessage(); - if (!message && !opts.amend) { + if (!message && !opts.amend && !opts.useEditor) { return false; } @@ -1550,10 +1558,13 @@ export class CommandCenter { private async commitWithAnyInput(repository: Repository, opts?: CommitOptions): Promise { const message = repository.inputBox.value; + const root = Uri.file(repository.root); + const config = workspace.getConfiguration('git', root); + const getCommitMessage = async () => { let _message: string | undefined = message; - if (!_message) { + if (!_message && !config.get('useEditorAsCommitInput')) { let value: string | undefined = undefined; if (opts && opts.amend && repository.HEAD && repository.HEAD.commit) { @@ -2937,7 +2948,7 @@ export class CommandCenter { }; let message: string; - let type: 'error' | 'warning' = 'error'; + let type: 'error' | 'warning' | 'information' = 'error'; const choices = new Map void>(); const openOutputChannelChoice = localize('open git log', "Open Git Log"); @@ -3000,6 +3011,12 @@ export class CommandCenter { message = localize('missing user info', "Make sure you configure your 'user.name' and 'user.email' in git."); choices.set(localize('learn more', "Learn More"), () => commands.executeCommand('vscode.open', Uri.parse('https://aka.ms/vscode-setup-git'))); break; + case GitErrorCodes.EmptyCommitMessage: + message = localize('empty commit', "Commit operation was cancelled due to empty commit message."); + choices.clear(); + type = 'information'; + options.modal = false; + break; default: { const hint = (err.stderr || err.message || String(err)) .replace(/^error: /mi, '') @@ -3021,10 +3038,20 @@ export class CommandCenter { return; } + let result: string | undefined; const allChoices = Array.from(choices.keys()); - const result = type === 'error' - ? await window.showErrorMessage(message, options, ...allChoices) - : await window.showWarningMessage(message, options, ...allChoices); + + switch (type) { + case 'error': + result = await window.showErrorMessage(message, options, ...allChoices); + break; + case 'warning': + result = await window.showWarningMessage(message, options, ...allChoices); + break; + case 'information': + result = await window.showInformationMessage(message, options, ...allChoices); + break; + } if (result) { const resultFn = choices.get(result); diff --git a/extensions/git/src/git.ts b/extensions/git/src/git.ts index 8fdab7f659b87..21fa0f0bf8ec5 100644 --- a/extensions/git/src/git.ts +++ b/extensions/git/src/git.ts @@ -1397,20 +1397,37 @@ export class Repository { } async commit(message: string | undefined, opts: CommitOptions = Object.create(null)): Promise { - const args = ['commit', '--quiet', '--allow-empty-message']; + const args = ['commit', '--quiet']; + const options: SpawnOptions = {}; + + if (message) { + options.input = message; + args.push('--file', '-'); + } + + if (opts.verbose) { + args.push('--verbose'); + } if (opts.all) { args.push('--all'); } - if (opts.amend && message) { + if (opts.amend) { args.push('--amend'); } - if (opts.amend && !message) { - args.push('--amend', '--no-edit'); - } else { - args.push('--file', '-'); + if (!opts.useEditor) { + if (!message) { + if (opts.amend) { + args.push('--no-edit'); + } else { + options.input = ''; + args.push('--file', '-'); + } + } + + args.push('--allow-empty-message'); } if (opts.signoff) { @@ -1435,7 +1452,7 @@ export class Repository { } try { - await this.exec(args, !opts.amend || message ? { input: message || '' } : {}); + await this.exec(args, options); } catch (commitErr) { await this.handleCommitError(commitErr); } @@ -1459,6 +1476,9 @@ export class Repository { if (/not possible because you have unmerged files/.test(commitErr.stderr || '')) { commitErr.gitErrorCode = GitErrorCodes.UnmergedChanges; throw commitErr; + } else if (/Aborting commit due to empty commit message/.test(commitErr.stderr || '')) { + commitErr.gitErrorCode = GitErrorCodes.EmptyCommitMessage; + throw commitErr; } try { diff --git a/extensions/git/src/gitEditor/gitEditor.ts b/extensions/git/src/gitEditor/gitEditor.ts new file mode 100644 index 0000000000000..34f8ee9f20fca --- /dev/null +++ b/extensions/git/src/gitEditor/gitEditor.ts @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'path'; +import { TabInputText, Uri, window, workspace } from 'vscode'; +import { IIPCHandler, IIPCServer } from '../ipc/ipcServer'; +import { EmptyDisposable, IDisposable } from '../util'; + +interface GitEditorRequest { + commitMessagePath?: string; +} + +export class GitEditor implements IIPCHandler { + + private disposable: IDisposable = EmptyDisposable; + + constructor(private ipc?: IIPCServer) { + if (ipc) { + this.disposable = ipc.registerHandler('git-editor', this); + } + } + + async handle({ commitMessagePath }: GitEditorRequest): Promise { + if (commitMessagePath) { + const uri = Uri.file(commitMessagePath); + const doc = await workspace.openTextDocument(uri); + await window.showTextDocument(doc, { preview: false }); + + return new Promise((c) => { + const onDidClose = window.tabGroups.onDidChangeTabs(async (tabs) => { + if (tabs.closed.some(t => t.input instanceof TabInputText && t.input.uri.toString() === uri.toString())) { + onDidClose.dispose(); + return c(true); + } + }); + }); + } + } + + getEnv(): { [key: string]: string } { + if (!this.ipc) { + const fileType = process.platform === 'win32' ? 'bat' : 'sh'; + const gitEditor = path.join(__dirname, `scripts/git-editor-empty.${fileType}`); + + return { + GIT_EDITOR: `'${gitEditor}'` + }; + } + + const fileType = process.platform === 'win32' ? 'bat' : 'sh'; + const gitEditor = path.join(__dirname, `scripts/git-editor.${fileType}`); + + return { + GIT_EDITOR: `'${gitEditor}'`, + VSCODE_GIT_EDITOR_NODE: process.execPath, + VSCODE_GIT_EDITOR_MAIN: path.join(__dirname, 'main.js') + }; + } + + dispose(): void { + this.disposable.dispose(); + } +} diff --git a/extensions/git/src/gitEditor/main.ts b/extensions/git/src/gitEditor/main.ts new file mode 100644 index 0000000000000..661540e003005 --- /dev/null +++ b/extensions/git/src/gitEditor/main.ts @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import { IPCClient } from '../ipc/ipcClient'; + +function fatal(err: any): void { + console.error(err); + process.exit(1); +} + +function main(argv: string[]): void { + const ipcClient = new IPCClient('git-editor'); + const commitMessagePath = argv[2]; + + ipcClient.call({ commitMessagePath }).then(() => { + setTimeout(() => process.exit(0), 0); + }).catch(err => fatal(err)); +} + +main(process.argv); diff --git a/extensions/git/src/gitEditor/scripts/git-editor-empty.bat b/extensions/git/src/gitEditor/scripts/git-editor-empty.bat new file mode 100644 index 0000000000000..56eeb8a5801a4 --- /dev/null +++ b/extensions/git/src/gitEditor/scripts/git-editor-empty.bat @@ -0,0 +1 @@ +@ECHO off diff --git a/extensions/git/src/gitEditor/scripts/git-editor-empty.sh b/extensions/git/src/gitEditor/scripts/git-editor-empty.sh new file mode 100755 index 0000000000000..1a2485251c33a --- /dev/null +++ b/extensions/git/src/gitEditor/scripts/git-editor-empty.sh @@ -0,0 +1 @@ +#!/bin/sh diff --git a/extensions/git/src/gitEditor/scripts/git-editor.bat b/extensions/git/src/gitEditor/scripts/git-editor.bat new file mode 100644 index 0000000000000..ce28807cae149 --- /dev/null +++ b/extensions/git/src/gitEditor/scripts/git-editor.bat @@ -0,0 +1,4 @@ +@ECHO off + +set ELECTRON_RUN_AS_NODE=1 +"%VSCODE_GIT_EDITOR_NODE%" "%VSCODE_GIT_EDITOR_MAIN%" %* diff --git a/extensions/git/src/gitEditor/scripts/git-editor.sh b/extensions/git/src/gitEditor/scripts/git-editor.sh new file mode 100755 index 0000000000000..f4ba2e4fd73d2 --- /dev/null +++ b/extensions/git/src/gitEditor/scripts/git-editor.sh @@ -0,0 +1,4 @@ +#!/bin/sh + +ELECTRON_RUN_AS_NODE="1" \ +"$VSCODE_GIT_EDITOR_NODE" "$VSCODE_GIT_EDITOR_MAIN" $@ diff --git a/extensions/git/src/main.ts b/extensions/git/src/main.ts index ee60d2cb1b1cc..e5d99158a4ea1 100644 --- a/extensions/git/src/main.ts +++ b/extensions/git/src/main.ts @@ -25,6 +25,8 @@ import { GitTimelineProvider } from './timelineProvider'; import { registerAPICommands } from './api/api1'; import { TerminalEnvironmentManager } from './terminal'; import { OutputChannelLogger } from './log'; +import { createIPCServer, IIPCServer } from './ipc/ipcServer'; +import { GitEditor } from './gitEditor/gitEditor'; const deactivateTasks: { (): Promise }[] = []; @@ -60,10 +62,21 @@ async function createModel(context: ExtensionContext, outputChannelLogger: Outpu return !skip; }); - const askpass = await Askpass.create(outputChannelLogger, context.storagePath); + let ipc: IIPCServer | undefined = undefined; + + try { + ipc = await createIPCServer(context.storagePath); + } catch (err) { + outputChannelLogger.logError(`Failed to create git IPC: ${err}`); + } + + const askpass = new Askpass(ipc); disposables.push(askpass); - const environment = askpass.getEnv(); + const gitEditor = new GitEditor(ipc); + disposables.push(gitEditor); + + const environment = { ...askpass.getEnv(), ...gitEditor.getEnv() }; const terminalEnvironmentManager = new TerminalEnvironmentManager(context, environment); disposables.push(terminalEnvironmentManager); diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index c337f91a7f530..b5ed89ac94b19 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -450,6 +450,13 @@ class ProgressManager { const onDidChange = filterEvent(workspace.onDidChangeConfiguration, e => e.affectsConfiguration('git', Uri.file(this.repository.root))); onDidChange(_ => this.updateEnablement()); this.updateEnablement(); + + this.repository.onDidChangeOperations(() => { + const commitInProgress = this.repository.operations.isRunning(Operation.Commit); + + this.repository.sourceControl.inputBox.enabled = !commitInProgress; + commands.executeCommand('setContext', 'commitInProgress', commitInProgress); + }); } private updateEnablement(): void { diff --git a/extensions/git/tsconfig.json b/extensions/git/tsconfig.json index 13997275056b0..899f8c9c33dcf 100644 --- a/extensions/git/tsconfig.json +++ b/extensions/git/tsconfig.json @@ -11,7 +11,9 @@ "src/**/*", "../../src/vscode-dts/vscode.d.ts", "../../src/vscode-dts/vscode.proposed.diffCommand.d.ts", + "../../src/vscode-dts/vscode.proposed.resolvers.d.ts", "../../src/vscode-dts/vscode.proposed.scmActionButton.d.ts", + "../../src/vscode-dts/vscode.proposed.scmInput.d.ts", "../../src/vscode-dts/vscode.proposed.scmSelectedProvider.d.ts", "../../src/vscode-dts/vscode.proposed.scmValidation.d.ts", "../../src/vscode-dts/vscode.proposed.tabs.d.ts", diff --git a/src/vs/workbench/contrib/scm/browser/util.ts b/src/vs/workbench/contrib/scm/browser/util.ts index 80d5d19302d5d..c8f4524d017ba 100644 --- a/src/vs/workbench/contrib/scm/browser/util.ts +++ b/src/vs/workbench/contrib/scm/browser/util.ts @@ -37,7 +37,7 @@ export function isSCMResource(element: any): element is ISCMResource { return !!(element as ISCMResource).sourceUri && isSCMResourceGroup((element as ISCMResource).resourceGroup); } -const compareActions = (a: IAction, b: IAction) => a.id === b.id; +const compareActions = (a: IAction, b: IAction) => a.id === b.id && a.enabled === b.enabled; export function connectPrimaryMenu(menu: IMenu, callback: (primary: IAction[], secondary: IAction[]) => void, primaryGroup?: string): IDisposable { let cachedDisposable: IDisposable = Disposable.None;