diff --git a/CHANGELOG.md b/CHANGELOG.md index c3cc2cf..91733ec 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/package.json b/package.json index eb975de..2a9753a 100644 --- a/package.json +++ b/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" }, { diff --git a/src/@types/vscode.git.d.ts b/src/@types/vscode.git.d.ts index 59b1052..aa5d8bf 100644 --- a/src/@types/vscode.git.d.ts +++ b/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; deleteBranch(name: string, force?: boolean): Promise; getBranch(name: string): Promise; - getBranches(query: BranchQuery): Promise; + getBranches(query: BranchQuery, cancellationToken?: CancellationToken): Promise; + getBranchBase(name: string): Promise; setBranchUpstream(name: string, upstream: string): Promise; + getRefs(query: RefQuery, cancellationToken?: CancellationToken): Promise; + getMergeBase(ref1: string, ref2: string): Promise; tag(name: string, upstream: string): Promise; @@ -233,6 +253,31 @@ export interface PushErrorHandler { ): Promise; } +export interface BranchProtection { + readonly remote: string; + readonly rules: BranchProtectionRule[]; +} + +export interface BranchProtectionRule { + readonly include?: string[]; + readonly exclude?: string[]; +} + +export interface BranchProtectionProvider { + onDidChangeBranchProtection: Event; + provideBranchProtection(): BranchProtection[]; +} + +export interface CommitMessageProvider { + readonly title: string; + readonly icon?: Uri | { light: Uri; dark: Uri } | ThemeIcon; + provideCommitMessage( + repository: Repository, + changes: string[], + cancellationToken?: CancellationToken, + ): Promise; +} + 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; - openRepository?(root: Uri): Promise; + init(root: Uri, options?: InitOptions): Promise; + openRepository(root: Uri): Promise; 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 { diff --git a/src/@types/vscode.git.enums.ts b/src/@types/vscode.git.enums.ts index 1e87702..862663a 100644 --- a/src/@types/vscode.git.enums.ts +++ b/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', } diff --git a/src/ai/aiProviderService.ts b/src/ai/aiProviderService.ts index f41cc6f..198e94b 100644 --- a/src/ai/aiProviderService.ts +++ b/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; + public async generateCommitMessage( + repoPath: Uri, + options?: { cancellation?: CancellationToken; context?: string; progress?: ProgressOptions }, ): Promise; public async generateCommitMessage( repository: Repository, - options?: { context?: string; progress?: ProgressOptions }, + options?: { cancellation?: CancellationToken; context?: string; progress?: ProgressOptions }, ): Promise; public async generateCommitMessage( - repoOrPath: string | Uri | Repository, - options?: { context?: string; progress?: ProgressOptions }, + changesOrRepoOrPath: string[] | Repository | Uri, + options?: { cancellation?: CancellationToken; context?: string; progress?: ProgressOptions }, ): Promise { - 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( diff --git a/src/commands/generateCommitMessage.ts b/src/commands/generateCommitMessage.ts index 3682027..33f8171 100644 --- a/src/commands/generateCommitMessage.ts +++ b/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; } diff --git a/src/config.ts b/src/config.ts index 7d4c0e9..39fcfd6 100644 --- a/src/config.ts +++ b/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; diff --git a/src/env/node/git/commitMessageProvider.ts b/src/env/node/git/commitMessageProvider.ts new file mode 100644 index 0000000..b63a305 --- /dev/null +++ b/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; +} diff --git a/src/env/node/git/localGitProvider.ts b/src/env/node/git/localGitProvider.ts index 14d3c94..ee3aed7 100644 --- a/src/env/node/git/localGitProvider.ts +++ b/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 | undefined; - private async getScmGitApi(): Promise { + private _scmGitApi: Promise | undefined; + private async getScmGitApi(): Promise { return this._scmGitApi ?? (this._scmGitApi = this.getScmGitApiCore()); } @log() - private async getScmGitApiCore(): Promise { + private async getScmGitApiCore(): Promise { try { const extension = extensions.getExtension('vscode.git'); if (extension == null) return undefined; @@ -5403,7 +5402,7 @@ export class LocalGitProvider implements GitProvider, Disposable { } @log() - private async openScmRepository(uri: Uri): Promise { + private async openScmRepository(uri: Uri): Promise { const scope = getLogScope(); try { const gitApi = await this.getScmGitApi();