Просмотр исходного кода

Adds commit message provider contribution

Allows commit message generation for unstaged changes too
main
Eric Amodio 1 год назад
Родитель
Сommit
41cae896a1
9 измененных файлов: 201 добавлений и 39 удалений
  1. +2
    -0
      CHANGELOG.md
  2. +11
    -4
      package.json
  3. +56
    -9
      src/@types/vscode.git.d.ts
  4. +8
    -0
      src/@types/vscode.git.enums.ts
  5. +32
    -14
      src/ai/aiProviderService.ts
  6. +3
    -3
      src/commands/generateCommitMessage.ts
  7. +3
    -0
      src/config.ts
  8. +78
    -0
      src/env/node/git/commitMessageProvider.ts
  9. +8
    -9
      src/env/node/git/localGitProvider.ts

+ 2
- 0
CHANGELOG.md Просмотреть файл

@ -15,6 +15,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
- Adds a `gitlens.focus.allowMultiple` setting to specify whether to allow opening multiple instances of the _Focus_ in the editor area
- Adds a _Split Visual File History_ command to the _Visual File History_ tab context menu
- Adds a `gitlens.visualHistory.allowMultiple` setting to specify whether to allow opening multiple instances of the _Visual File History_ in the editor area
- Adds a _Generate Commit Message (Experimental)_ button to the SCM input when supported (currently `1.84.0-insider` only)
- Adds a `gitlens.ai.experimental.generateCommitMessage.enabled` setting to specify whether to enable GitLens' experimental, AI-powered, on-demand commit message generation
- Improves the experience of the _Search Commits_ quick pick menu
- Adds a stateful authors picker to make it much easier to search for commits by specific authors
- Adds a file and folder picker to make it much easier to search for commits containing specific files or in specific folders

+ 11
- 4
package.json Просмотреть файл

@ -3139,12 +3139,19 @@
"title": "AI",
"order": 113,
"properties": {
"gitlens.ai.experimental.generateCommitMessage.enabled": {
"type": "boolean",
"default": true,
"markdownDescription": "Specifies whether to enable GitLens' experimental, AI-powered, on-demand commit message generation",
"scope": "window",
"order": 1
},
"gitlens.experimental.generateCommitMessagePrompt": {
"type": "string",
"default": "Commit messages must have a short description that is less than 50 chars followed by a newline and a more detailed description.\n- Write concisely using an informal tone and avoid specific names from the code",
"markdownDescription": "Specifies the prompt to use to tell OpenAI how to structure or format the generated commit message",
"scope": "window",
"order": 1
"order": 2
},
"gitlens.ai.experimental.provider": {
"type": "string",
@ -10517,7 +10524,7 @@
},
{
"command": "gitlens.generateCommitMessage",
"when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders"
"when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && config.gitlens.ai.experimental.generateCommitMessage.enabled"
},
{
"command": "gitlens.resetAIKey",
@ -10862,7 +10869,7 @@
},
{
"command": "gitlens.generateCommitMessage",
"when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && config.gitlens.menus.scmRepository.generateCommitMessage",
"when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && config.gitlens.ai.experimental.generateCommitMessage.enabled && config.gitlens.menus.scmRepository.generateCommitMessage",
"group": "4_gitlens@2"
}
],
@ -10898,7 +10905,7 @@
},
{
"command": "gitlens.generateCommitMessage",
"when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && scmProvider == git && config.gitlens.menus.scmRepository.generateCommitMessage",
"when": "gitlens:enabled && !gitlens:readonly && !gitlens:untrusted && !gitlens:hasVirtualFolders && config.gitlens.ai.experimental.generateCommitMessage.enabled && scmProvider == git && config.gitlens.menus.scmRepository.generateCommitMessage",
"group": "2_z_gitlens@2"
},
{

+ 56
- 9
src/@types/vscode.git.d.ts Просмотреть файл

@ -4,7 +4,6 @@
*--------------------------------------------------------------------------------------------*/
import { Disposable, Event, ProviderResult, Uri, Command } from 'vscode';
import { GitErrorCodes, RefType, Status, ForcePushMode } from '../@types/vscode.git.enums';
export interface Git {
@ -94,6 +93,10 @@ export interface LogOptions {
/** Max number of log entries to retrieve. If not specified, the default is 32. */
readonly maxEntries?: number;
readonly path?: string;
/** A commit range, such as "0a47c67f0fb52dd11562af48658bc1dff1d75a38..0bb4bdea78e1db44d728fd6894720071e303304f" */
readonly range?: string;
readonly reverse?: boolean;
readonly sortByAuthorDate?: boolean;
}
export interface CommitOptions {
@ -106,7 +109,13 @@ export interface CommitOptions {
requireUserConfig?: boolean;
useEditor?: boolean;
verbose?: boolean;
postCommitCommand?: string;
/**
* string - execute the specified command after the commit operation
* undefined - execute the command specified in git.postCommitCommand
* after the commit operation
* null - do not execute any command after the commit operation
*/
postCommitCommand?: string | null;
}
export interface FetchOptions {
@ -117,11 +126,19 @@ export interface FetchOptions {
depth?: number;
}
export interface BranchQuery {
readonly remote?: boolean;
readonly pattern?: string;
readonly count?: number;
export interface InitOptions {
defaultBranch?: string;
}
export interface RefQuery {
readonly contains?: string;
readonly count?: number;
readonly pattern?: string;
readonly sort?: 'alphabetically' | 'committerdate';
}
export interface BranchQuery extends RefQuery {
readonly remote?: boolean;
}
export interface Repository {
@ -164,9 +181,12 @@ export interface Repository {
createBranch(name: string, checkout: boolean, ref?: string): Promise<void>;
deleteBranch(name: string, force?: boolean): Promise<void>;
getBranch(name: string): Promise<Branch>;
getBranches(query: BranchQuery): Promise<Ref[]>;
getBranches(query: BranchQuery, cancellationToken?: CancellationToken): Promise<Ref[]>;
getBranchBase(name: string): Promise<Branch | undefined>;
setBranchUpstream(name: string, upstream: string): Promise<void>;
getRefs(query: RefQuery, cancellationToken?: CancellationToken): Promise<Ref[]>;
getMergeBase(ref1: string, ref2: string): Promise<string>;
tag(name: string, upstream: string): Promise<void>;
@ -233,6 +253,31 @@ export interface PushErrorHandler {
): Promise<boolean>;
}
export interface BranchProtection {
readonly remote: string;
readonly rules: BranchProtectionRule[];
}
export interface BranchProtectionRule {
readonly include?: string[];
readonly exclude?: string[];
}
export interface BranchProtectionProvider {
onDidChangeBranchProtection: Event<Uri>;
provideBranchProtection(): BranchProtection[];
}
export interface CommitMessageProvider {
readonly title: string;
readonly icon?: Uri | { light: Uri; dark: Uri } | ThemeIcon;
provideCommitMessage(
repository: Repository,
changes: string[],
cancellationToken?: CancellationToken,
): Promise<string | undefined>;
}
export type APIState = 'uninitialized' | 'initialized';
export interface PublishEvent {
@ -251,14 +296,16 @@ export interface API {
toGitUri(uri: Uri, ref: string): Uri;
getRepository(uri: Uri): Repository | null;
init(root: Uri): Promise<Repository | null>;
openRepository?(root: Uri): Promise<Repository | null>;
init(root: Uri, options?: InitOptions): Promise<Repository | null>;
openRepository(root: Uri): Promise<Repository | null>;
registerRemoteSourcePublisher(publisher: RemoteSourcePublisher): Disposable;
registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable;
registerCredentialsProvider(provider: CredentialsProvider): Disposable;
registerPostCommitCommandsProvider(provider: PostCommitCommandsProvider): Disposable;
registerPushErrorHandler(handler: PushErrorHandler): Disposable;
registerBranchProtectionProvider(root: Uri, provider: BranchProtectionProvider): Disposable;
registerCommitMessageProvider(provider: CommitMessageProvider): Disposable;
}
export interface GitExtension {

+ 8
- 0
src/@types/vscode.git.enums.ts Просмотреть файл

@ -6,6 +6,7 @@
export const enum ForcePushMode {
Force,
ForceWithLease,
ForceWithLeaseIfIncludes,
}
export const enum RefType {
@ -26,6 +27,8 @@ export const enum Status {
UNTRACKED,
IGNORED,
INTENT_TO_ADD,
INTENT_TO_RENAME,
TYPE_CHANGED,
ADDED_BY_US,
ADDED_BY_THEM,
@ -48,6 +51,8 @@ export const enum GitErrorCodes {
StashConflict = 'StashConflict',
UnmergedChanges = 'UnmergedChanges',
PushRejected = 'PushRejected',
ForcePushWithLeaseRejected = 'ForcePushWithLeaseRejected',
ForcePushWithLeaseIfIncludesRejected = 'ForcePushWithLeaseIfIncludesRejected',
RemoteConnectionError = 'RemoteConnectionError',
DirtyWorkTree = 'DirtyWorkTree',
CantOpenResource = 'CantOpenResource',
@ -73,4 +78,7 @@ export const enum GitErrorCodes {
NoPathFound = 'NoPathFound',
UnknownPath = 'UnknownPath',
EmptyCommitMessage = 'EmptyCommitMessage',
BranchFastForwardRejected = 'BranchFastForwardRejected',
BranchNotYetBorn = 'BranchNotYetBorn',
TagConflict = 'TagConflict',
}

+ 32
- 14
src/ai/aiProviderService.ts Просмотреть файл

@ -1,10 +1,10 @@
import type { Disposable, MessageItem, ProgressOptions } from 'vscode';
import type { CancellationToken, Disposable, MessageItem, ProgressOptions } from 'vscode';
import { Uri, window } from 'vscode';
import type { AIProviders } from '../constants';
import type { Container } from '../container';
import type { GitCommit } from '../git/models/commit';
import { assertsCommitHasFullDetails, isCommit } from '../git/models/commit';
import { uncommittedStaged } from '../git/models/constants';
import { uncommitted, uncommittedStaged } from '../git/models/constants';
import type { GitRevisionReference } from '../git/models/reference';
import type { Repository } from '../git/models/repository';
import { isRepository } from '../git/models/repository';
@ -50,34 +50,52 @@ export class AIProviderService implements Disposable {
}
public async generateCommitMessage(
repoPath: string | Uri,
options?: { context?: string; progress?: ProgressOptions },
changes: string[],
options?: { cancellation?: CancellationToken; context?: string; progress?: ProgressOptions },
): Promise<string | undefined>;
public async generateCommitMessage(
repoPath: Uri,
options?: { cancellation?: CancellationToken; context?: string; progress?: ProgressOptions },
): Promise<string | undefined>;
public async generateCommitMessage(
repository: Repository,
options?: { context?: string; progress?: ProgressOptions },
options?: { cancellation?: CancellationToken; context?: string; progress?: ProgressOptions },
): Promise<string | undefined>;
public async generateCommitMessage(
repoOrPath: string | Uri | Repository,
options?: { context?: string; progress?: ProgressOptions },
changesOrRepoOrPath: string[] | Repository | Uri,
options?: { cancellation?: CancellationToken; context?: string; progress?: ProgressOptions },
): Promise<string | undefined> {
const repository = isRepository(repoOrPath) ? repoOrPath : this.container.git.getRepository(repoOrPath);
if (repository == null) throw new Error('Unable to find repository');
const diff = await this.container.git.getDiff(repository.uri, uncommittedStaged);
if (diff == null) throw new Error('No staged changes to generate a commit message from.');
let changes: string;
if (Array.isArray(changesOrRepoOrPath)) {
changes = changesOrRepoOrPath.join('\n');
} else {
const repository = isRepository(changesOrRepoOrPath)
? changesOrRepoOrPath
: this.container.git.getRepository(changesOrRepoOrPath);
if (repository == null) throw new Error('Unable to find repository');
let diff = await this.container.git.getDiff(repository.uri, uncommittedStaged);
if (diff == null) {
diff = await this.container.git.getDiff(repository.uri, uncommitted);
if (diff == null) throw new Error('No changes to generate a commit message from.');
}
if (options?.cancellation?.isCancellationRequested) return undefined;
changes = diff.contents;
}
const provider = this.provider;
const confirmed = await confirmAIProviderToS(provider, this.container.storage);
if (!confirmed) return undefined;
if (options?.cancellation?.isCancellationRequested) return undefined;
if (options?.progress != null) {
return window.withProgress(options.progress, async () =>
provider.generateCommitMessage(diff.contents, { context: options?.context }),
provider.generateCommitMessage(changes, { context: options?.context }),
);
}
return provider.generateCommitMessage(diff.contents, { context: options?.context });
return provider.generateCommitMessage(changes, { context: options?.context });
}
async explainCommit(

+ 3
- 3
src/commands/generateCommitMessage.ts Просмотреть файл

@ -46,12 +46,12 @@ export class GenerateCommitMessageCommand extends ActiveEditorCommand {
if (message == null) return;
void executeCoreCommand('workbench.view.scm');
scmRepo.inputBox.value = `${currentMessage ? `${currentMessage}\n\n` : ''}${message}`;
scmRepo.inputBox.value = currentMessage ? `${currentMessage}\n\n${message}` : message;
} catch (ex) {
Logger.error(ex, 'GenerateCommitMessageCommand');
if (ex instanceof Error && ex.message.startsWith('No staged changes')) {
void window.showInformationMessage('No staged changes to generate a commit message from.');
if (ex instanceof Error && ex.message.startsWith('No changes')) {
void window.showInformationMessage('No changes to generate a commit message from.');
return;
}

+ 3
- 0
src/config.ts Просмотреть файл

@ -6,6 +6,9 @@ import type { LogLevel } from './system/logger.constants';
export interface Config {
readonly ai: {
readonly experimental: {
readonly generateCommitMessage: {
readonly enabled: boolean;
};
readonly provider: 'openai' | 'anthropic';
readonly openai: {
readonly model?: OpenAIModels;

+ 78
- 0
src/env/node/git/commitMessageProvider.ts Просмотреть файл

@ -0,0 +1,78 @@
import type { CancellationToken, ConfigurationChangeEvent, Disposable } from 'vscode';
import { ProgressLocation, ThemeIcon, window } from 'vscode';
import type {
CommitMessageProvider,
API as ScmGitApi,
Repository as ScmGitRepository,
} from '../../../@types/vscode.git';
import type { Container } from '../../../container';
import { configuration } from '../../../system/configuration';
import { log } from '../../../system/decorators/log';
import { Logger } from '../../../system/logger';
import { getLogScope } from '../../../system/logger.scope';
class AICommitMessageProvider implements CommitMessageProvider, Disposable {
icon: ThemeIcon = new ThemeIcon('sparkle');
title: string = 'Generate Commit Message (Experimental)';
private readonly _disposable: Disposable;
private _subscription: Disposable | undefined;
constructor(
private readonly container: Container,
private readonly scmGit: ScmGitApi,
) {
this._disposable = configuration.onDidChange(this.onConfigurationChanged, this);
this.onConfigurationChanged();
}
private onConfigurationChanged(e?: ConfigurationChangeEvent) {
if (e == null || configuration.changed(e, 'ai.experimental.generateCommitMessage.enabled')) {
if (configuration.get('ai.experimental.generateCommitMessage.enabled')) {
this._subscription = this.scmGit.registerCommitMessageProvider(this);
} else {
this._subscription?.dispose();
this._subscription = undefined;
}
}
}
dispose() {
this._subscription?.dispose();
this._disposable.dispose();
}
@log({ args: false })
async provideCommitMessage(repository: ScmGitRepository, changes: string[], cancellation: CancellationToken) {
const scope = getLogScope();
const currentMessage = repository.inputBox.value;
try {
const message = await this.container.ai.generateCommitMessage(changes, {
cancellation: cancellation,
context: currentMessage,
progress: {
location: ProgressLocation.Notification,
title: 'Generating commit message...',
},
});
return currentMessage ? `${currentMessage}\n\n${message}` : message;
} catch (ex) {
Logger.error(scope, ex);
if (ex instanceof Error && ex.message.startsWith('No changes')) {
void window.showInformationMessage('No changes to generate a commit message from.');
return;
}
return undefined;
}
}
}
export function registerCommitMessageProvider(container: Container, scmGit: ScmGitApi): Disposable | undefined {
return typeof scmGit.registerCommitMessageProvider === 'function'
? new AICommitMessageProvider(container, scmGit)
: undefined;
}

+ 8
- 9
src/env/node/git/localGitProvider.ts Просмотреть файл

@ -8,11 +8,7 @@ import { md5 } from '@env/crypto';
import { fetch, getProxyAgent } from '@env/fetch';
import { hrtime } from '@env/hrtime';
import { isLinux, isWindows } from '@env/platform';
import type {
API as BuiltInGitApi,
Repository as BuiltInGitRepository,
GitExtension,
} from '../../../@types/vscode.git';
import type { GitExtension, API as ScmGitApi } from '../../../@types/vscode.git';
import { getCachedAvatarUri } from '../../../avatars';
import type { CoreConfiguration, CoreGitConfiguration } from '../../../constants';
import { GlyphChars, Schemes } from '../../../constants';
@ -183,6 +179,7 @@ import { serializeWebviewItemContext } from '../../../system/webview';
import type { CachedBlame, CachedDiff, CachedLog } from '../../../trackers/gitDocumentTracker';
import { GitDocumentState } from '../../../trackers/gitDocumentTracker';
import type { TrackedDocument } from '../../../trackers/trackedDocument';
import { registerCommitMessageProvider } from './commitMessageProvider';
import type { Git, PushForceOptions } from './git';
import {
getShaInLogRegex,
@ -353,6 +350,8 @@ export class LocalGitProvider implements GitProvider, Disposable {
const scmGit = await scmGitPromise;
if (scmGit == null) return;
registerCommitMessageProvider(this.container, scmGit);
// Find env to pass to Git
if (configuration.get('experimental.nativeGit')) {
for (const v of Object.values(scmGit.git)) {
@ -5328,13 +5327,13 @@ export class LocalGitProvider implements GitProvider, Disposable {
}
}
private _scmGitApi: Promise<BuiltInGitApi | undefined> | undefined;
private async getScmGitApi(): Promise<BuiltInGitApi | undefined> {
private _scmGitApi: Promise<ScmGitApi | undefined> | undefined;
private async getScmGitApi(): Promise<ScmGitApi | undefined> {
return this._scmGitApi ?? (this._scmGitApi = this.getScmGitApiCore());
}
@log()
private async getScmGitApiCore(): Promise<BuiltInGitApi | undefined> {
private async getScmGitApiCore(): Promise<ScmGitApi | undefined> {
try {
const extension = extensions.getExtension<GitExtension>('vscode.git');
if (extension == null) return undefined;
@ -5403,7 +5402,7 @@ export class LocalGitProvider implements GitProvider, Disposable {
}
@log()
private async openScmRepository(uri: Uri): Promise<BuiltInGitRepository | undefined> {
private async openScmRepository(uri: Uri): Promise<ScmRepository | undefined> {
const scope = getLogScope();
try {
const gitApi = await this.getScmGitApi();

Загрузка…
Отмена
Сохранить