Skip to content

Commit

Permalink
Adds commit message provider contribution
Browse files Browse the repository at this point in the history
Allows commit message generation for unstaged changes too
  • Loading branch information
eamodio committed Oct 30, 2023
1 parent 90509a2 commit 41cae89
Show file tree
Hide file tree
Showing 9 changed files with 201 additions and 39 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 11 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
],
Expand Down Expand Up @@ -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"
},
{
Expand Down
65 changes: 56 additions & 9 deletions src/@types/vscode.git.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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>;
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions src/@types/vscode.git.enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
export const enum ForcePushMode {
Force,
ForceWithLease,
ForceWithLeaseIfIncludes,
}

export const enum RefType {
Expand All @@ -26,6 +27,8 @@ export const enum Status {
UNTRACKED,
IGNORED,
INTENT_TO_ADD,
INTENT_TO_RENAME,
TYPE_CHANGED,

ADDED_BY_US,
ADDED_BY_THEM,
Expand All @@ -48,6 +51,8 @@ export const enum GitErrorCodes {
StashConflict = 'StashConflict',
UnmergedChanges = 'UnmergedChanges',
PushRejected = 'PushRejected',
ForcePushWithLeaseRejected = 'ForcePushWithLeaseRejected',
ForcePushWithLeaseIfIncludesRejected = 'ForcePushWithLeaseIfIncludesRejected',
RemoteConnectionError = 'RemoteConnectionError',
DirtyWorkTree = 'DirtyWorkTree',
CantOpenResource = 'CantOpenResource',
Expand All @@ -73,4 +78,7 @@ export const enum GitErrorCodes {
NoPathFound = 'NoPathFound',
UnknownPath = 'UnknownPath',
EmptyCommitMessage = 'EmptyCommitMessage',
BranchFastForwardRejected = 'BranchFastForwardRejected',
BranchNotYetBorn = 'BranchNotYetBorn',
TagConflict = 'TagConflict',
}
46 changes: 32 additions & 14 deletions src/ai/aiProviderService.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(
Expand Down
6 changes: 3 additions & 3 deletions src/commands/generateCommitMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
3 changes: 3 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
78 changes: 78 additions & 0 deletions src/env/node/git/commitMessageProvider.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading

0 comments on commit 41cae89

Please sign in to comment.