diff --git a/packages/build/src/types.ts b/packages/build/src/types.ts index 18db4f2e7c..89f95c0a75 100644 --- a/packages/build/src/types.ts +++ b/packages/build/src/types.ts @@ -31,7 +31,7 @@ const commandsSchema = z.array( command: z.string(), title: z.string(), category: z.string().optional(), - icon: z.string().optional(), + icon: z.union([z.string(), z.object({ light: z.string(), dark: z.string() })]).optional(), }), ) diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 288f6c2118..178a26e6fc 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -232,6 +232,8 @@ export const globalSettingsSchema = z.object({ * Tools in this list will be excluded from prompt generation and rejected at execution time. */ disabledTools: z.array(toolNamesSchema).optional(), + + commitMessageApiConfigId: z.string().optional(), }) export type GlobalSettings = z.infer diff --git a/packages/types/src/telemetry.ts b/packages/types/src/telemetry.ts index 402cd571c8..1e9d80f202 100644 --- a/packages/types/src/telemetry.ts +++ b/packages/types/src/telemetry.ts @@ -74,6 +74,8 @@ export enum TelemetryEventName { TELEMETRY_SETTINGS_CHANGED = "Telemetry Settings Changed", MODEL_CACHE_EMPTY_RESPONSE = "Model Cache Empty Response", READ_FILE_LEGACY_FORMAT_USED = "Read File Legacy Format Used", + + COMMIT_MSG_GENERATED = "Commit Message Generated", } /** @@ -206,6 +208,7 @@ export const rooCodeTelemetryEventSchema = z.discriminatedUnion("type", [ TelemetryEventName.MODE_SETTINGS_CHANGED, TelemetryEventName.CUSTOM_MODE_CREATED, TelemetryEventName.READ_FILE_LEGACY_FORMAT_USED, + TelemetryEventName.COMMIT_MSG_GENERATED, ]), properties: telemetryPropertiesSchema, }), diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 202326eb01..5d949443d2 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -288,6 +288,7 @@ export type ExtensionState = Pick< | "customModePrompts" | "customSupportPrompts" | "enhancementApiConfigId" + | "commitMessageApiConfigId" | "customCondensingPrompt" | "codebaseIndexConfig" | "codebaseIndexModels" diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index ad339f52a4..31fcc5cdb3 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2098,6 +2098,7 @@ export class ClineProvider customModePrompts, customSupportPrompts, enhancementApiConfigId, + commitMessageApiConfigId, autoApprovalEnabled, customModes, experiments, @@ -2250,6 +2251,7 @@ export class ClineProvider customModePrompts: customModePrompts ?? {}, customSupportPrompts: customSupportPrompts ?? {}, enhancementApiConfigId, + commitMessageApiConfigId, autoApprovalEnabled: autoApprovalEnabled ?? false, customModes, experiments: experiments ?? experimentDefault, @@ -2456,6 +2458,7 @@ export class ClineProvider customModePrompts: stateValues.customModePrompts ?? {}, customSupportPrompts: stateValues.customSupportPrompts ?? {}, enhancementApiConfigId: stateValues.enhancementApiConfigId, + commitMessageApiConfigId: stateValues.commitMessageApiConfigId, experiments: stateValues.experiments ?? experimentDefault, autoApprovalEnabled: stateValues.autoApprovalEnabled ?? false, customModes, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index dc029cb7dd..22165016f2 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1606,7 +1606,6 @@ export const webviewMessageHandler = async ( await updateGlobalState("enhancementApiConfigId", message.text) await provider.postStateToWebview() break - case "autoApprovalEnabled": await updateGlobalState("autoApprovalEnabled", message.bool ?? false) await provider.postStateToWebview() diff --git a/src/extension.ts b/src/extension.ts index 44c1243528..58ee685144 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -50,6 +50,7 @@ import { import { initializeI18n } from "./i18n" import { initializeModelCacheRefresh } from "./api/providers/fetchers/modelCache" import { initZooCodeAuth } from "./services/zoo-code-auth" +import { registerCommitMessageProvider } from "./services/commit-message" /** * Built using https://github.com/microsoft/vscode-webview-ui-toolkit @@ -256,6 +257,14 @@ export async function activate(context: vscode.ExtensionContext) { registerCommands({ context, outputChannel, provider }) + try { + registerCommitMessageProvider(context, outputChannel) + } catch (error) { + outputChannel.appendLine( + `Failed to register commit message provider: ${error instanceof Error ? error.message : String(error)}`, + ) + } + /** * We use the text document content provider API to show the left side for diff * view by creating a virtual document for the original content. This makes it diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 157a87c5dc..de4f767ce7 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -259,5 +259,56 @@ "connected": "Zoo Code: Successfully connected! You can now use Zoo Code as your AI provider.", "disconnected": "Zoo Code: Disconnected successfully." } + }, + "commitMessage": { + "activated": "Zoo Code commit message generator activated", + "gitNotFound": "⚠️ Git repository not found or git not available", + "gitInitError": "⚠️ Git initialization error: {{error}}", + "generating": "Zoo: Generating commit message...", + "noChanges": "Zoo: No changes found to analyze", + "generated": "Zoo: Commit message generated!", + "generationFailed": "Zoo: Failed to generate commit message: {{errorMessage}}", + "contextWarnings": "Zoo: Git context warning: {{warnings}}", + "generatingFromUnstaged": "Zoo: Generating message using unstaged changes", + "activationFailed": "Zoo: Failed to activate message generator: {{error}}", + "providerRegistered": "Zoo: Commit message provider registered", + "initializing": "Initializing...", + "discoveringFiles": "Discovering files...", + "foundChanges": "Found {{count}} changes", + "gettingContext": "Getting git context...", + "errors": { + "connectionFailed": "Failed to connect to Zoo Code extension", + "timeout": "Request timed out after 30 seconds", + "invalidResponse": "Invalid response format received from extension", + "missingMessage": "No commit message received from extension", + "noChanges": "No changes found to commit", + "noProject": "No project available", + "noWorkspacePath": "Could not determine workspace path for Git repository", + "workspaceNotFound": "Could not determine workspace path for Git repository", + "processingError": "Error processing commit message generation: {{error}}" + }, + "error": { + "title": "Error", + "workspacePathNotFound": "Could not determine workspace path for Git repository", + "generationFailed": "Failed to generate commit message: {{error}}", + "processingFailed": "Error processing commit message generation: {{error}}", + "unknown": "Unknown error" + }, + "dialogs": { + "info": "AI Commit Message", + "error": "Error", + "success": "Success", + "title": "AI Commit Message" + }, + "progress": { + "title": "Generating Commit Message", + "analyzing": "Analyzing changes...", + "connecting": "Connecting to Zoo Code...", + "generating": "Generating commit message..." + }, + "ui": { + "generateButton": "Generate Commit Message", + "generateButtonTooltip": "Generates commit message using AI to analyze your code changes" + } } } diff --git a/src/package.json b/src/package.json index 1fe7ed788b..d0ae61a049 100644 --- a/src/package.json +++ b/src/package.json @@ -164,6 +164,14 @@ "command": "zoo-code.toggleAutoApprove", "title": "%command.toggleAutoApprove.title%", "category": "%configuration.title%" + }, + { + "command": "zoo-code.generateCommitMessage", + "title": "%command.generateCommitMessage.title%", + "icon": { + "light": "assets/icons/panel_light.png", + "dark": "assets/icons/panel_dark.png" + } } ], "menus": { @@ -207,6 +215,19 @@ "group": "1_actions@3" } ], + "scm/input": [ + { + "command": "zoo-code.generateCommitMessage", + "group": "navigation" + } + ], + "scm/title": [ + { + "command": "zoo-code.generateCommitMessage", + "when": "scmProvider == git", + "group": "navigation" + } + ], "view/title": [ { "command": "zoo-code.plusButtonClicked", diff --git a/src/package.nls.json b/src/package.nls.json index 23c9b02d92..7aa9485f8d 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -24,6 +24,7 @@ "command.terminal.explainCommand.title": "Explain This Command", "command.acceptInput.title": "Accept Input/Suggestion", "command.toggleAutoApprove.title": "Toggle Auto-Approve", + "command.generateCommitMessage.title": "Generate Commit Message with Zoo", "configuration.title": "Zoo Code", "commands.allowedCommands.description": "Commands that can be auto-executed when 'Always approve execute operations' is enabled", "commands.deniedCommands.description": "Command prefixes that will be automatically denied without asking for approval. In case of conflicts with allowed commands, the longest prefix match takes precedence. Add * to deny all commands.", diff --git a/src/services/commit-message/CommitMessageGenerator.ts b/src/services/commit-message/CommitMessageGenerator.ts new file mode 100644 index 0000000000..ad0efd813c --- /dev/null +++ b/src/services/commit-message/CommitMessageGenerator.ts @@ -0,0 +1,199 @@ +import { ContextProxy } from "../../core/config/ContextProxy" +import { ProviderSettingsManager } from "../../core/config/ProviderSettingsManager" +import { singleCompletionHandler as defaultSingleCompletionHandler } from "../../utils/single-completion-handler" +import { supportPrompt } from "../../shared/support-prompt" +import { addCustomInstructions as defaultAddCustomInstructions } from "../../core/prompts/sections/custom-instructions" +import { TelemetryService } from "@roo-code/telemetry" +import { TelemetryEventName, type ProviderSettings } from "@roo-code/types" + +import { GenerateMessageParams, PromptOptions, ProgressUpdate } from "./types/core" + +export interface CommitMessageContextProxy { + isInitialized: boolean + getProviderSettings(): ProviderSettings + getValue(key: any): unknown +} + +export interface CommitMessageGeneratorDependencies { + getContextProxy?: () => CommitMessageContextProxy + completePrompt?: (apiConfiguration: ProviderSettings, promptText: string) => Promise + addCustomInstructions?: typeof defaultAddCustomInstructions + captureGenerated?: () => void + logger?: Pick +} + +export class CommitMessageGenerator { + private readonly providerSettingsManager: ProviderSettingsManager + private readonly dependencies: Required + private previousGitContext: string | null = null + private previousCommitMessage: string | null = null + + constructor( + providerSettingsManager: ProviderSettingsManager, + dependencies: CommitMessageGeneratorDependencies = {}, + ) { + this.providerSettingsManager = providerSettingsManager + this.dependencies = { + getContextProxy: dependencies.getContextProxy ?? (() => ContextProxy.instance), + completePrompt: dependencies.completePrompt ?? defaultSingleCompletionHandler, + addCustomInstructions: dependencies.addCustomInstructions ?? defaultAddCustomInstructions, + captureGenerated: + dependencies.captureGenerated ?? + (() => TelemetryService.instance.captureEvent(TelemetryEventName.COMMIT_MSG_GENERATED)), + logger: dependencies.logger ?? console, + } + } + + async generateMessage(params: GenerateMessageParams): Promise { + const { gitContext, onProgress } = params + + try { + onProgress?.({ + message: "Generating commit message...", + percentage: 75, + }) + + const generatedMessage = await this.callAIForCommitMessage(gitContext, params.workspacePath, onProgress) + + this.previousGitContext = gitContext + this.previousCommitMessage = generatedMessage + + this.dependencies.captureGenerated() + + onProgress?.({ + message: "Commit message generated successfully", + percentage: 100, + }) + + return generatedMessage + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred" + throw new Error(`Failed to generate commit message: ${errorMessage}`) + } + } + + async buildPrompt(gitContext: string, options: PromptOptions, workspacePath: string): Promise { + const { customSupportPrompts = {}, previousContext, previousMessage } = options + + const customInstructions = await this.dependencies.addCustomInstructions("", "", workspacePath, "commit", { + language: "en", + }) + + const shouldGenerateDifferentMessage = + (previousContext === gitContext || this.previousGitContext === gitContext) && + (previousMessage !== null || this.previousCommitMessage !== null) + + const targetPreviousMessage = previousMessage || this.previousCommitMessage + + if (shouldGenerateDifferentMessage && targetPreviousMessage) { + const differentMessagePrefix = `# CRITICAL INSTRUCTION: GENERATE A COMPLETELY DIFFERENT COMMIT MESSAGE +The user has requested a new commit message for the same changes. +The previous message was: "${targetPreviousMessage}" +YOU MUST create a message that is COMPLETELY DIFFERENT by: +- Using entirely different wording and phrasing +- Focusing on different aspects of the changes +- Using a different structure or format if appropriate +- Possibly using a different type or scope if justifiable +This is the MOST IMPORTANT requirement for this task. + +` + const baseTemplate = supportPrompt.get(customSupportPrompts, "COMMIT_MESSAGE") + const modifiedTemplate = + differentMessagePrefix + + baseTemplate + + ` + +FINAL REMINDER: Your message MUST be COMPLETELY DIFFERENT from the previous message: "${targetPreviousMessage}". This is a critical requirement.` + + return supportPrompt.create( + "COMMIT_MESSAGE", + { + gitContext, + customInstructions: customInstructions || "", + }, + { + ...customSupportPrompts, + COMMIT_MESSAGE: modifiedTemplate, + }, + ) + } else { + return supportPrompt.create( + "COMMIT_MESSAGE", + { + gitContext, + customInstructions: customInstructions || "", + }, + customSupportPrompts, + ) + } + } + + private async callAIForCommitMessage( + gitContextString: string, + workspacePath: string, + onProgress?: (progress: ProgressUpdate) => void, + ): Promise { + const contextProxy = this.dependencies.getContextProxy() + if (!contextProxy.isInitialized) { + throw new Error("ContextProxy not initialized. Please try again after the extension has fully loaded.") + } + const apiConfiguration = contextProxy.getProviderSettings() + const commitMessageApiConfigId = contextProxy.getValue("commitMessageApiConfigId") as string | undefined + const listApiConfigMeta = (contextProxy.getValue("listApiConfigMeta") || []) as Array<{ id: string }> + const customSupportPrompts = (contextProxy.getValue("customSupportPrompts") || {}) as Record< + string, + string | undefined + > + + let configToUse: ProviderSettings = apiConfiguration + + if (commitMessageApiConfigId && listApiConfigMeta.find(({ id }) => id === commitMessageApiConfigId)) { + try { + await this.providerSettingsManager.initialize() + const { name: _, ...providerSettings } = await this.providerSettingsManager.getProfile({ + id: commitMessageApiConfigId, + }) + + if (providerSettings.apiProvider) { + configToUse = providerSettings + } + } catch (error) { + this.dependencies.logger.warn( + `Failed to load commit message API profile ${commitMessageApiConfigId}; falling back to current API configuration`, + error, + ) + } + } + + const filteredPrompts = Object.fromEntries( + Object.entries(customSupportPrompts).filter(([_, value]) => value !== undefined), + ) as Record + + const prompt = await this.buildPrompt( + gitContextString, + { customSupportPrompts: filteredPrompts }, + workspacePath, + ) + + onProgress?.({ + message: "Calling AI service...", + increment: 10, + }) + + const response = await this.dependencies.completePrompt(configToUse, prompt) + + onProgress?.({ + message: "Processing AI response...", + increment: 10, + }) + + return this.extractCommitMessage(response) + } + + private extractCommitMessage(response: string): string { + const cleaned = response.trim() + const withoutCodeBlocks = cleaned.replace(/```[a-z]*\n|```/g, "") + const withoutQuotes = withoutCodeBlocks.replace(/^["'`]|["'`]$/g, "") + return withoutQuotes.trim() + } +} diff --git a/src/services/commit-message/CommitMessageProvider.ts b/src/services/commit-message/CommitMessageProvider.ts new file mode 100644 index 0000000000..8a0628c8cc --- /dev/null +++ b/src/services/commit-message/CommitMessageProvider.ts @@ -0,0 +1,181 @@ +import * as vscode from "vscode" +import { ProviderSettingsManager } from "../../core/config/ProviderSettingsManager" +import { t } from "../../i18n" +import { Package } from "../../shared/package" +import { GitChange, GitContextCollector } from "../git-context" + +import { CommitMessageGenerator } from "./CommitMessageGenerator" + +interface VscGenerationRequest { + inputBox: { value: string } + rootUri?: vscode.Uri +} + +export class CommitMessageProvider implements vscode.Disposable { + private generator: CommitMessageGenerator + + constructor( + private context: vscode.ExtensionContext, + private outputChannel: vscode.OutputChannel, + ) { + const providerSettingsManager = new ProviderSettingsManager(this.context) + + this.generator = new CommitMessageGenerator(providerSettingsManager) + } + + public async activate(): Promise { + this.outputChannel.appendLine(t("common:commitMessage.activated")) + + const disposables = [ + vscode.commands.registerCommand( + `${Package.name}.generateCommitMessage`, + (vsRequest?: VscGenerationRequest) => this.handleVSCodeCommand(vsRequest), + ), + ] + this.context.subscriptions.push(...disposables) + } + + private async handleVSCodeCommand(vsRequest?: VscGenerationRequest): Promise { + try { + const workspacePath = this.determineWorkspacePath(vsRequest?.rootUri) + const targetRepository = await this.determineTargetRepository(workspacePath) + if (!targetRepository?.rootUri) { + throw new Error("Could not determine Git repository") + } + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.SourceControl, + title: t("common:commitMessage.generating"), + cancellable: false, + }, + async (progress) => { + let lastPercentage = 0 + const reportProgress = (percentage: number, message?: string) => { + progress.report({ + increment: Math.max(0, percentage - lastPercentage), + message: message || t("common:commitMessage.generating"), + }) + lastPercentage = percentage + } + + reportProgress(5, t("common:commitMessage.initializing")) + const gitCollector = new GitContextCollector(workspacePath) + + try { + reportProgress(15, t("common:commitMessage.discoveringFiles")) + const resolution = await this.resolveCommitChanges(gitCollector) + + if (resolution.changes.length === 0) { + vscode.window.showInformationMessage(t("common:commitMessage.noChanges")) + return + } + + reportProgress(25, t("common:commitMessage.foundChanges", { count: resolution.changes.length })) + + if (!resolution.usedStaged) { + vscode.window.showInformationMessage(t("common:commitMessage.generatingFromUnstaged")) + } + + reportProgress(40, t("common:commitMessage.gettingContext")) + const gitContextResult = await gitCollector.collectContext( + resolution.changes, + { staged: resolution.usedStaged, includeRepoContext: true }, + resolution.files, + ) + if (gitContextResult.warnings.length > 0) { + vscode.window.showWarningMessage( + t("common:commitMessage.contextWarnings", { + warnings: gitContextResult.warnings.join("; "), + }), + ) + } + + reportProgress(70, t("common:commitMessage.generating")) + const message = await this.generator.generateMessage({ + workspacePath, + selectedFiles: resolution.files, + gitContext: gitContextResult.context, + onProgress: (update) => { + if (update.percentage !== undefined) { + reportProgress(70 + update.percentage * 0.25, update.message) + } + }, + }) + + targetRepository.inputBox.value = message + reportProgress(100, t("common:commitMessage.generated")) + } finally { + gitCollector.dispose() + } + }, + ) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred" + vscode.window.showErrorMessage(t("common:commitMessage.generationFailed", { errorMessage })) + } + } + + private async resolveCommitChanges(gitCollector: GitContextCollector): Promise<{ + changes: GitChange[] + files: string[] + usedStaged: boolean + }> { + let changes = await gitCollector.gatherChanges({ staged: true }) + let usedStaged = true + + if (changes.length === 0) { + changes = await gitCollector.gatherChanges({ staged: false }) + usedStaged = false + } + + return { + changes, + files: changes.map((change) => change.filePath), + usedStaged, + } + } + + private async determineTargetRepository(workspacePath: string): Promise { + try { + const gitExtension = vscode.extensions.getExtension("vscode.git") + if (!gitExtension) { + return null + } + + if (!gitExtension.isActive) { + await gitExtension.activate() + } + + const gitApi = gitExtension.exports.getAPI(1) + if (!gitApi) { + return null + } + + for (const repo of gitApi.repositories ?? []) { + if (repo.rootUri && workspacePath.startsWith(repo.rootUri.fsPath)) { + return repo + } + } + + return gitApi.repositories[0] ?? null + } catch (error) { + return null + } + } + + private determineWorkspacePath(resourceUri?: vscode.Uri): string { + if (resourceUri) { + return resourceUri.fsPath + } + + const workspaceFolders = vscode.workspace.workspaceFolders + if (workspaceFolders && workspaceFolders.length > 0) { + return workspaceFolders[0].uri.fsPath + } + + throw new Error("Could not determine workspace path") + } + + public dispose(): void {} +} diff --git a/src/services/commit-message/__tests__/CommitMessageGeneration.integration.spec.ts b/src/services/commit-message/__tests__/CommitMessageGeneration.integration.spec.ts new file mode 100644 index 0000000000..5239de3167 --- /dev/null +++ b/src/services/commit-message/__tests__/CommitMessageGeneration.integration.spec.ts @@ -0,0 +1,79 @@ +import * as os from "os" +import * as path from "path" +import { execFile } from "child_process" +import { promisify } from "util" +import { promises as fs } from "fs" +import type { ProviderSettings } from "@roo-code/types" + +import { GitContextCollector } from "../../git-context" +import { CommitMessageGenerator } from "../CommitMessageGenerator" + +const execFileAsync = promisify(execFile) + +async function runGit(cwd: string, args: string[]) { + await execFileAsync("git", args, { cwd }) +} + +describe("commit message generation flow", () => { + const defaultConfig: ProviderSettings = { apiProvider: "openai", openAiApiKey: "default-key" } + const providerSettingsManager = { + initialize: vi.fn(), + getProfile: vi.fn(), + } + const contextProxy = { + isInitialized: true, + getProviderSettings: vi.fn(() => defaultConfig), + getValue: vi.fn((key: string) => { + switch (key) { + case "listApiConfigMeta": + return [] + case "customSupportPrompts": + return {} + default: + return undefined + } + }), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("passes collected git context with untracked file diff to the LLM", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "zoo-commit-generation-")) + try { + await runGit(tempRoot, ["init"]) + const filePath = path.join(tempRoot, "src", "new.ts") + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, "export const value = 1\n") + + const gitContext = await new GitContextCollector(tempRoot).collect({ + staged: false, + includeRepoContext: false, + }) + const completePrompt = vi.fn().mockResolvedValue("feat(src): add new module") + const generator = new CommitMessageGenerator(providerSettingsManager as any, { + getContextProxy: () => contextProxy, + completePrompt, + addCustomInstructions: vi.fn().mockResolvedValue(""), + captureGenerated: vi.fn(), + }) + + const message = await generator.generateMessage({ + workspacePath: tempRoot, + selectedFiles: gitContext.changes.map((change) => change.filePath), + gitContext: gitContext.context, + }) + + expect(message).toBe("feat(src): add new module") + expect(gitContext.context).toContain("diff --git a/src/new.ts b/src/new.ts") + expect(gitContext.context).toContain("+export const value = 1") + expect(completePrompt).toHaveBeenCalledWith( + defaultConfig, + expect.stringContaining("+export const value = 1"), + ) + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }) + } + }) +}) diff --git a/src/services/commit-message/__tests__/CommitMessageGenerator.spec.ts b/src/services/commit-message/__tests__/CommitMessageGenerator.spec.ts new file mode 100644 index 0000000000..299d4d6764 --- /dev/null +++ b/src/services/commit-message/__tests__/CommitMessageGenerator.spec.ts @@ -0,0 +1,156 @@ +import type { ProviderSettings } from "@roo-code/types" + +import { CommitMessageGenerator } from "../CommitMessageGenerator" + +describe("CommitMessageGenerator", () => { + const defaultConfig: ProviderSettings = { apiProvider: "openai", openAiApiKey: "default-key" } + const commitConfig: ProviderSettings = { apiProvider: "anthropic", apiKey: "commit-key" } + const providerSettingsManager = { + initialize: vi.fn(), + getProfile: vi.fn(), + } + const contextProxy = { + isInitialized: true, + getProviderSettings: vi.fn(), + getValue: vi.fn(), + } + const completePrompt = vi.fn() + const addCustomInstructions = vi.fn() + const captureGenerated = vi.fn() + const warn = vi.fn() + + const createGenerator = () => + new CommitMessageGenerator(providerSettingsManager as any, { + getContextProxy: () => contextProxy, + completePrompt, + addCustomInstructions: addCustomInstructions as any, + captureGenerated, + logger: { warn }, + }) + + beforeEach(() => { + vi.clearAllMocks() + contextProxy.isInitialized = true + contextProxy.getProviderSettings.mockReturnValue(defaultConfig) + contextProxy.getValue.mockImplementation((key: string) => { + switch (key) { + case "commitMessageApiConfigId": + return undefined + case "listApiConfigMeta": + return [] + case "customSupportPrompts": + return {} + default: + return undefined + } + }) + addCustomInstructions.mockResolvedValue("Follow repo commit rules.") + completePrompt.mockResolvedValue("```\nfeat(core): add commit generator\n```") + providerSettingsManager.initialize.mockResolvedValue(undefined) + providerSettingsManager.getProfile.mockResolvedValue({ name: "Commit profile", ...commitConfig }) + }) + + it("sends the full git context to the LLM and returns cleaned commit text", async () => { + const gitContext = `## Git Context + +### Full Diff of Staged Changes +\`\`\`diff +diff --git a/src/new.ts b/src/new.ts +new file mode 100644 +--- /dev/null ++++ b/src/new.ts +@@ -0,0 +1,1 @@ ++export const value = 1 +\`\`\`` + const generator = createGenerator() + + const message = await generator.generateMessage({ + workspacePath: "/repo", + selectedFiles: ["src/new.ts"], + gitContext, + }) + + expect(message).toBe("feat(core): add commit generator") + expect(completePrompt).toHaveBeenCalledTimes(1) + const [config, prompt] = completePrompt.mock.calls[0] + expect(config).toBe(defaultConfig) + expect(prompt).toContain("# Conventional Commit Message Generator") + expect(prompt).toContain("Follow repo commit rules.") + expect(prompt).toContain(gitContext) + expect(captureGenerated).toHaveBeenCalledTimes(1) + }) + + it("uses the selected commit-message API profile when configured", async () => { + contextProxy.getValue.mockImplementation((key: string) => { + switch (key) { + case "commitMessageApiConfigId": + return "commit-profile" + case "listApiConfigMeta": + return [{ id: "commit-profile", name: "Commit profile" }] + case "customSupportPrompts": + return {} + default: + return undefined + } + }) + completePrompt.mockResolvedValue("fix(git): include untracked file diffs") + const generator = createGenerator() + + await generator.generateMessage({ + workspacePath: "/repo", + selectedFiles: ["src/new.ts"], + gitContext: "diff --git a/src/new.ts b/src/new.ts", + }) + + expect(providerSettingsManager.initialize).toHaveBeenCalledTimes(1) + expect(providerSettingsManager.getProfile).toHaveBeenCalledWith({ id: "commit-profile" }) + expect(completePrompt).toHaveBeenCalledWith( + expect.objectContaining(commitConfig), + expect.stringContaining("diff --git a/src/new.ts b/src/new.ts"), + ) + }) + + it("falls back to current API config when the selected profile cannot be loaded", async () => { + contextProxy.getValue.mockImplementation((key: string) => { + switch (key) { + case "commitMessageApiConfigId": + return "deleted-profile" + case "listApiConfigMeta": + return [{ id: "deleted-profile", name: "Deleted profile" }] + case "customSupportPrompts": + return {} + default: + return undefined + } + }) + providerSettingsManager.getProfile.mockRejectedValue(new Error("missing profile")) + const generator = createGenerator() + + await generator.generateMessage({ + workspacePath: "/repo", + selectedFiles: ["src/new.ts"], + gitContext: "diff --git a/src/new.ts b/src/new.ts", + }) + + expect(completePrompt).toHaveBeenCalledWith(defaultConfig, expect.any(String)) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("Failed to load commit message API profile deleted-profile"), + expect.any(Error), + ) + }) + + it("asks for a different message when regenerating for the same git context", async () => { + completePrompt.mockResolvedValueOnce("feat(git): collect git context") + completePrompt.mockResolvedValueOnce("chore(git): improve diff handling") + const generator = createGenerator() + const gitContext = "diff --git a/src/file.ts b/src/file.ts" + + await generator.generateMessage({ workspacePath: "/repo", selectedFiles: ["src/file.ts"], gitContext }) + await generator.generateMessage({ workspacePath: "/repo", selectedFiles: ["src/file.ts"], gitContext }) + + const secondPrompt = completePrompt.mock.calls[1][1] + expect(secondPrompt).toContain("GENERATE A COMPLETELY DIFFERENT COMMIT MESSAGE") + expect(secondPrompt).toContain('The previous message was: "feat(git): collect git context"') + expect(secondPrompt).toContain(gitContext) + }) +}) diff --git a/src/services/commit-message/index.ts b/src/services/commit-message/index.ts new file mode 100644 index 0000000000..095596386e --- /dev/null +++ b/src/services/commit-message/index.ts @@ -0,0 +1,18 @@ +import * as vscode from "vscode" +import { CommitMessageProvider } from "./CommitMessageProvider" +import { t } from "../../i18n" + +export function registerCommitMessageProvider( + context: vscode.ExtensionContext, + outputChannel: vscode.OutputChannel, +): void { + const commitProvider = new CommitMessageProvider(context, outputChannel) + context.subscriptions.push(commitProvider) + + commitProvider.activate().catch((error) => { + outputChannel.appendLine(t("common:commitMessage.activationFailed", { error: error.message })) + console.error("Commit message provider activation failed:", error) + }) + + outputChannel.appendLine(t("common:commitMessage.providerRegistered")) +} diff --git a/src/services/commit-message/types/core.ts b/src/services/commit-message/types/core.ts new file mode 100644 index 0000000000..3bfed717a7 --- /dev/null +++ b/src/services/commit-message/types/core.ts @@ -0,0 +1,18 @@ +export interface GenerateMessageParams { + workspacePath: string + selectedFiles: string[] + gitContext: string + onProgress?: (progress: ProgressUpdate) => void +} + +export interface PromptOptions { + customSupportPrompts?: Record + previousContext?: string + previousMessage?: string +} + +export interface ProgressUpdate { + message?: string + percentage?: number + increment?: number +} diff --git a/src/services/git-context/GitContextCollector.ts b/src/services/git-context/GitContextCollector.ts new file mode 100644 index 0000000000..8019416abc --- /dev/null +++ b/src/services/git-context/GitContextCollector.ts @@ -0,0 +1,430 @@ +import * as path from "path" +import { promises as fs } from "fs" +import { spawn } from "child_process" +import { + GitContextCollection, + GitContextCollectorOptions, + GitChange, + GitContextOptions, + GitContextResult, + GitStatus, +} from "./types" + +export type { + GitChange, + GitContextCollection, + GitContextCollectorOptions, + GitContextOptions, + GitContextResult, +} from "./types" + +export class GitContextCollector { + constructor(private workspaceRoot: string) {} + + public async gatherChanges(options: GitContextCollectorOptions): Promise { + const statusOutput = await this.getStatus(options) + if (!statusOutput) { + return [] + } + + return options.staged ? this.parseNameStatus(statusOutput, true) : this.parsePorcelainStatus(statusOutput) + } + + public async collect(options: GitContextCollectorOptions, specificFiles?: string[]): Promise { + const changes = await this.gatherChanges(options) + const result = await this.collectContext(changes, options, specificFiles) + + return { ...result, changes } + } + + private async runGit(args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn("git", args, { + cwd: this.workspaceRoot, + stdio: ["ignore", "pipe", "pipe"], + }) + let stdout = "" + let stderr = "" + + child.stdout.setEncoding("utf8") + child.stderr.setEncoding("utf8") + child.stdout.on("data", (chunk) => (stdout += chunk)) + child.stderr.on("data", (chunk) => (stderr += chunk)) + child.on("error", reject) + child.on("close", (code) => { + if (code === 0) { + resolve(stdout) + return + } + + reject(new Error(`Git command failed (${args.join(" ")}): ${stderr.trim() || `exit code ${code}`}`)) + }) + }) + } + + private async getDiffForChanges(changes: GitChange[], options: GitContextCollectorOptions): Promise { + if (changes.length === 0) { + return "" + } + + const binaryChanges = await this.findBinaryChanges(changes, options.staged) + const diffableChanges = changes.filter((change) => change.status !== "?" && !binaryChanges.has(change.filePath)) + const untrackedFiles = changes.filter((change) => change.status === "?") + const parts: string[] = [] + + if (diffableChanges.length > 0) { + const diffArgs = this.buildDiffArgs(options.staged, diffableChanges) + const diff = await this.runGit(diffArgs) + if (diff.trim()) { + parts.push(diff) + } + } + + if (untrackedFiles.length > 0) { + parts.push(await this.getUntrackedFileDiffs(untrackedFiles)) + } + + if (binaryChanges.size > 0) { + parts.push( + changes + .filter((change) => binaryChanges.has(change.filePath)) + .map( + (change) => + `Binary file ${this.getReadableStatus(change.status).toLowerCase()}: ${this.getRelativePath(change.filePath)}`, + ) + .join("\n"), + ) + } + + options.onProgress?.(100) + return parts.join("\n") + } + + private async findBinaryChanges(changes: GitChange[], staged: boolean): Promise> { + const binaryFiles = new Set() + + for (const change of changes) { + if (change.status === "?") { + continue + } + + const args = this.buildNumstatArgs(staged, change) + const output = await this.runGit(args) + if (output.includes("-\t-\t")) { + binaryFiles.add(change.filePath) + } + } + + return binaryFiles + } + + private buildNumstatArgs(staged: boolean, change: GitChange): string[] { + const args = staged ? ["diff", "--cached", "--numstat"] : ["diff", "--numstat"] + return [...args, "--", this.getRelativePath(change.filePath)] + } + + private async isProbablyBinaryFile(filePath: string): Promise { + const fileHandle = await fs.open(filePath, "r") + try { + const buffer = Buffer.alloc(8000) + const { bytesRead } = await fileHandle.read(buffer, 0, buffer.length, 0) + return buffer.subarray(0, bytesRead).includes(0) + } finally { + await fileHandle.close() + } + } + + private async getUntrackedFileDiffs(changes: GitChange[]): Promise { + const diffs: string[] = [] + + for (const change of changes) { + if (await this.isProbablyBinaryFile(change.filePath)) { + diffs.push(`Binary file added: ${this.getRelativePath(change.filePath)}`) + continue + } + + diffs.push(await this.createNewFileDiff(change.filePath)) + } + + return diffs.join("\n") + } + + private async createNewFileDiff(filePath: string): Promise { + const relativePath = this.getRelativePath(filePath) + const content = await fs.readFile(filePath, "utf8") + const normalizedContent = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n") + + if (normalizedContent.length === 0) { + return [ + `diff --git a/${relativePath} b/${relativePath}`, + "new file mode 100644", + "--- /dev/null", + `+++ b/${relativePath}`, + ].join("\n") + } + + const hasTrailingNewline = normalizedContent.endsWith("\n") + const lines = (hasTrailingNewline ? normalizedContent.slice(0, -1) : normalizedContent).split("\n") + const diffLines = [ + `diff --git a/${relativePath} b/${relativePath}`, + "new file mode 100644", + "--- /dev/null", + `+++ b/${relativePath}`, + `@@ -0,0 +1,${lines.length} @@`, + ...lines.map((line) => `+${line}`), + ] + + if (!hasTrailingNewline) { + diffLines.push("\\ No newline at end of file") + } + + return diffLines.join("\n") + } + + private async getStatus(options: GitContextOptions): Promise { + return options.staged + ? this.runGit(["diff", "--name-status", "--cached", "-z"]) + : this.runGit(["status", "--porcelain=v1", "-z", "--untracked-files=all"]) + } + + private async getCurrentBranch(): Promise { + return this.runGit(["branch", "--show-current"]) + } + + private async getRecentCommits(count: number = 5): Promise { + return this.runGit(["log", "--oneline", `-${count}`]) + } + + public async collectContext( + changes: GitChange[], + options: GitContextCollectorOptions, + specificFiles?: string[], + ): Promise { + const { staged, includeRepoContext = true } = options + let context = "## Git Context\n\n" + const warnings: string[] = [] + + const targetChanges = this.filterChanges(changes, specificFiles) + const fileInfo = specificFiles ? ` (${specificFiles.length} selected files)` : "" + const allStaged = targetChanges.every((change) => change.staged) + const allUnstaged = targetChanges.every((change) => !change.staged) + const changeDescriptor = allStaged ? "Staged" : allUnstaged ? "Unstaged" : "Selected" + + const diff = await this.getDiffForChanges(targetChanges, options) + context += `### Full Diff of ${changeDescriptor} Changes${fileInfo}\n\`\`\`diff\n${diff}\n\`\`\`\n\n` + + if (targetChanges.length > 0) { + const summaryLines = targetChanges.map((change) => { + const relativePath = this.getRelativePath(change.filePath) + const scope = change.staged ? "staged" : "unstaged" + const status = this.getReadableStatus(change.status) + + if (change.oldFilePath) { + const oldRelativePath = this.getRelativePath(change.oldFilePath) + return `${status} (${scope}): ${oldRelativePath} -> ${relativePath}` + } + + return `${status} (${scope}): ${relativePath}` + }) + + context += "### Change Summary\n```\n" + summaryLines.join("\n") + "\n```\n\n" + } else { + context += "### Change Summary\n```\n(No changes matched selection)\n```\n\n" + } + + if (includeRepoContext) { + context += "### Repository Context\n\n" + + try { + const currentBranch = await this.getCurrentBranch() + if (currentBranch) { + context += "**Current branch:** `" + currentBranch.trim() + "`\n\n" + } + } catch (error) { + warnings.push(`Current branch unavailable: ${this.getErrorMessage(error)}`) + } + + try { + const recentCommits = await this.getRecentCommits() + if (recentCommits) { + context += "**Recent commits:**\n```\n" + recentCommits + "\n```\n" + } + } catch (error) { + warnings.push(`Recent commits unavailable: ${this.getErrorMessage(error)}`) + } + } + + if (warnings.length > 0) { + context += "\n### Git Context Warnings\n```\n" + warnings.join("\n") + "\n```\n" + } + + return { context, warnings } + } + + public async getContext( + changes: GitChange[], + options: GitContextCollectorOptions, + specificFiles?: string[], + ): Promise { + return (await this.collectContext(changes, options, specificFiles)).context + } + + private getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) + } + + private parseNameStatus(output: string, staged: boolean): GitChange[] { + const fields = this.splitNullDelimited(output) + const changes: GitChange[] = [] + + for (let index = 0; index < fields.length; index++) { + const statusCode = fields[index] + const status = this.getChangeStatusFromCode(statusCode) + + if (status === "R" || status === "C") { + const oldFilePath = fields[++index] + const filePath = fields[++index] + if (oldFilePath && filePath) { + changes.push({ + filePath: path.join(this.workspaceRoot, filePath), + oldFilePath: path.join(this.workspaceRoot, oldFilePath), + status, + staged, + }) + } + continue + } + + const filePath = fields[++index] + if (filePath) { + changes.push({ + filePath: path.join(this.workspaceRoot, filePath), + status, + staged, + }) + } + } + + return changes + } + + private parsePorcelainStatus(output: string): GitChange[] { + const fields = this.splitNullDelimited(output) + const changes: GitChange[] = [] + + for (let index = 0; index < fields.length; index++) { + const entry = fields[index] + if (entry.length < 4) { + continue + } + + const indexStatus = entry.charAt(0) + const workingStatus = entry.charAt(1) + const statusCode = indexStatus === "?" && workingStatus === "?" ? "?" : workingStatus.trim() || indexStatus + const status = this.getChangeStatusFromCode(statusCode) + const filePath = entry.substring(3) + + if (status === "R" || status === "C") { + const oldFilePath = fields[++index] + changes.push({ + filePath: path.join(this.workspaceRoot, filePath), + oldFilePath: oldFilePath ? path.join(this.workspaceRoot, oldFilePath) : undefined, + status, + staged: false, + }) + continue + } + + changes.push({ + filePath: path.join(this.workspaceRoot, filePath), + status, + staged: false, + }) + } + + return changes + } + + private splitNullDelimited(output: string): string[] { + return output.split("\0").filter(Boolean) + } + + private filterChanges(changes: GitChange[], specificFiles?: string[]): GitChange[] { + if (!specificFiles || specificFiles.length === 0) { + return changes + } + + return changes.filter((change) => { + const absolutePath = change.filePath + const relativePath = this.getRelativePath(absolutePath) + return specificFiles.some((file) => { + const normalizedFile = path.normalize(file).replace(/\\/g, "/") + return ( + file === absolutePath || + file === relativePath || + absolutePath.endsWith(file) || + relativePath === normalizedFile + ) + }) + }) + } + + private buildDiffArgs(staged: boolean, changes: GitChange[]): string[] { + const args = staged ? ["diff", "--cached"] : ["diff"] + const paths = Array.from( + new Set( + changes.flatMap((change) => + [change.filePath, change.oldFilePath] + .filter((filePath): filePath is string => Boolean(filePath)) + .map((filePath) => this.getRelativePath(filePath)), + ), + ), + ) + + return paths.length > 0 ? [...args, "--", ...paths] : args + } + + private getRelativePath(filePath: string): string { + return path.relative(this.workspaceRoot, filePath).replace(/\\/g, "/") + } + + private getChangeStatusFromCode(code: string): GitStatus { + const status = code.charAt(0) + switch (status) { + case "M": + case "A": + case "D": + case "R": + case "C": + case "U": + case "?": + return status as GitStatus + default: + return "Unknown" + } + } + + private getReadableStatus(status: GitStatus): string { + switch (status) { + case "M": + return "Modified" + case "A": + return "Added" + case "D": + return "Deleted" + case "R": + return "Renamed" + case "C": + return "Copied" + case "U": + return "Updated" + case "?": + return "Untracked" + case "Unknown": + default: + return "Unknown" + } + } + + public dispose(): void {} +} diff --git a/src/services/git-context/__tests__/GitContextCollector.spec.ts b/src/services/git-context/__tests__/GitContextCollector.spec.ts new file mode 100644 index 0000000000..133b6cc94c --- /dev/null +++ b/src/services/git-context/__tests__/GitContextCollector.spec.ts @@ -0,0 +1,196 @@ +import * as os from "os" +import * as path from "path" +import { EventEmitter } from "events" +import { promises as fs } from "fs" +import { spawn } from "child_process" + +import { GitContextCollector } from "../GitContextCollector" + +vi.mock("child_process", () => ({ + spawn: vi.fn(), +})) + +const mockSpawn = vi.mocked(spawn) +const workspaceRoot = path.resolve("/repo") + +function mockGitCommand(stdout: string, stderr = "", code = 0) { + mockSpawn.mockImplementationOnce((() => { + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter & { setEncoding: ReturnType } + stderr: EventEmitter & { setEncoding: ReturnType } + } + + child.stdout = Object.assign(new EventEmitter(), { setEncoding: vi.fn() }) + child.stderr = Object.assign(new EventEmitter(), { setEncoding: vi.fn() }) + + queueMicrotask(() => { + if (stdout) { + child.stdout.emit("data", stdout) + } + + if (stderr) { + child.stderr.emit("data", stderr) + } + + child.emit("close", code) + }) + + return child + }) as unknown as typeof spawn) +} + +describe("GitContextCollector", () => { + beforeEach(() => { + mockSpawn.mockReset() + }) + + it("parses staged name-status output including renames and copies", async () => { + mockGitCommand( + ["M", "src/file.ts", "R100", "src/old.ts", "src/new.ts", "C075", "src/a.ts", "src/b.ts", ""].join("\0"), + ) + + const collector = new GitContextCollector(workspaceRoot) + const changes = await collector.gatherChanges({ staged: true }) + + expect(mockSpawn).toHaveBeenCalledWith( + "git", + ["diff", "--name-status", "--cached", "-z"], + expect.objectContaining({ cwd: workspaceRoot }), + ) + expect(changes).toEqual([ + { filePath: path.join(workspaceRoot, "src/file.ts"), status: "M", staged: true }, + { + filePath: path.join(workspaceRoot, "src/new.ts"), + oldFilePath: path.join(workspaceRoot, "src/old.ts"), + status: "R", + staged: true, + }, + { + filePath: path.join(workspaceRoot, "src/b.ts"), + oldFilePath: path.join(workspaceRoot, "src/a.ts"), + status: "C", + staged: true, + }, + ]) + }) + + it("requests all untracked files instead of collapsed untracked directories", async () => { + mockGitCommand(["?? src/new.ts", ""].join("\0")) + + const collector = new GitContextCollector(workspaceRoot) + const changes = await collector.gatherChanges({ staged: false }) + + expect(mockSpawn).toHaveBeenCalledWith( + "git", + ["status", "--porcelain=v1", "-z", "--untracked-files=all"], + expect.objectContaining({ cwd: workspaceRoot }), + ) + expect(changes).toEqual([{ filePath: path.join(workspaceRoot, "src/new.ts"), status: "?", staged: false }]) + }) + + it("keeps lockfiles in git context because git state is authoritative", async () => { + mockGitCommand("1\t1\tpackage-lock.json\n") + mockGitCommand("diff --git a/package-lock.json b/package-lock.json\n") + + const collector = new GitContextCollector(workspaceRoot) + const context = await collector.getContext( + [{ filePath: path.join(workspaceRoot, "package-lock.json"), status: "M", staged: true }], + { staged: true, includeRepoContext: false }, + ) + + expect(context).toContain("diff --git a/package-lock.json b/package-lock.json") + expect(context).toContain("Modified (staged): package-lock.json") + }) + + it("can gather changes and collect context in one reusable call", async () => { + mockGitCommand(["M", "src/file.ts", ""].join("\0")) + mockGitCommand("1\t1\tsrc/file.ts\n") + mockGitCommand("diff --git a/src/file.ts b/src/file.ts\n") + + const collector = new GitContextCollector(workspaceRoot) + const result = await collector.collect({ staged: true, includeRepoContext: false }) + + expect(result.changes).toEqual([ + { filePath: path.join(workspaceRoot, "src/file.ts"), status: "M", staged: true }, + ]) + expect(result.context).toContain("diff --git a/src/file.ts b/src/file.ts") + expect(result.warnings).toEqual([]) + }) + + it("includes full new-file diffs for untracked text files", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "zoo-git-context-")) + try { + const filePath = path.join(tempRoot, "src", "new.ts") + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, "export const value = 1\n") + + const collector = new GitContextCollector(tempRoot) + const context = await collector.getContext([{ filePath, status: "?", staged: false }], { + staged: false, + includeRepoContext: false, + }) + + expect(mockSpawn).not.toHaveBeenCalled() + expect(context).toContain("diff --git a/src/new.ts b/src/new.ts") + expect(context).toContain("--- /dev/null") + expect(context).toContain("+export const value = 1") + expect(context).toContain("Untracked (unstaged): src/new.ts") + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }) + } + }) + + it("summarizes untracked binary files without binary payload", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "zoo-git-context-")) + try { + const filePath = path.join(tempRoot, "image.bin") + await fs.writeFile(filePath, Buffer.from([0, 1, 2, 3])) + + const collector = new GitContextCollector(tempRoot) + const context = await collector.getContext([{ filePath, status: "?", staged: false }], { + staged: false, + includeRepoContext: false, + }) + + expect(context).toContain("Binary file added: image.bin") + expect(context).not.toContain("@@ -0,0") + expect(context).toContain("Untracked (unstaged): image.bin") + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }) + } + }) + + it("fails required diff collection instead of emitting partial context", async () => { + mockGitCommand("1\t1\tsrc/file.ts\n") + mockGitCommand("", "fatal: bad revision", 128) + + const collector = new GitContextCollector(workspaceRoot) + + await expect( + collector.getContext([{ filePath: path.join(workspaceRoot, "src/file.ts"), status: "M", staged: true }], { + staged: true, + includeRepoContext: false, + }), + ).rejects.toThrow("fatal: bad revision") + }) + + it("returns warnings when supplemental repository context is unavailable", async () => { + mockGitCommand("1\t1\tsrc/file.ts\n") + mockGitCommand("diff --git a/src/file.ts b/src/file.ts\n") + mockGitCommand("", "fatal: branch unavailable", 128) + mockGitCommand("", "fatal: log unavailable", 128) + + const collector = new GitContextCollector(workspaceRoot) + const result = await collector.collectContext( + [{ filePath: path.join(workspaceRoot, "src/file.ts"), status: "M", staged: true }], + { staged: true, includeRepoContext: true }, + ) + + expect(result.warnings).toEqual([ + expect.stringContaining("Current branch unavailable"), + expect.stringContaining("Recent commits unavailable"), + ]) + expect(result.context).toContain("### Git Context Warnings") + expect(result.context).toContain("diff --git a/src/file.ts b/src/file.ts") + }) +}) diff --git a/src/services/git-context/index.ts b/src/services/git-context/index.ts new file mode 100644 index 0000000000..93f3828411 --- /dev/null +++ b/src/services/git-context/index.ts @@ -0,0 +1,9 @@ +export { GitContextCollector } from "./GitContextCollector" +export type { + GitChange, + GitContextCollection, + GitContextCollectorOptions, + GitContextOptions, + GitContextResult, + GitStatus, +} from "./types" diff --git a/src/services/git-context/types.ts b/src/services/git-context/types.ts new file mode 100644 index 0000000000..d440523d6d --- /dev/null +++ b/src/services/git-context/types.ts @@ -0,0 +1,26 @@ +export type GitStatus = "M" | "A" | "D" | "R" | "C" | "U" | "?" | "Unknown" + +export interface GitChange { + filePath: string + oldFilePath?: string + status: GitStatus + staged: boolean +} + +export interface GitContextResult { + context: string + warnings: string[] +} + +export interface GitContextCollection extends GitContextResult { + changes: GitChange[] +} + +export interface GitContextOptions { + staged: boolean +} + +export interface GitContextCollectorOptions extends GitContextOptions { + onProgress?: (percentage: number) => void + includeRepoContext?: boolean +} diff --git a/src/shared/support-prompt.ts b/src/shared/support-prompt.ts index da14c4367f..3755de9e59 100644 --- a/src/shared/support-prompt.ts +++ b/src/shared/support-prompt.ts @@ -44,6 +44,7 @@ type SupportPromptType = | "TERMINAL_FIX" | "TERMINAL_EXPLAIN" | "NEW_TASK" + | "COMMIT_MESSAGE" const supportPromptConfigs: Record = { ENHANCE: { @@ -240,6 +241,79 @@ Please provide: NEW_TASK: { template: `\${userInput}`, }, + COMMIT_MESSAGE: { + template: `# Conventional Commit Message Generator +## System Instructions +You are an expert Git commit message generator that creates conventional commit messages based on staged changes. Analyze the provided git diff output and generate appropriate conventional commit messages following the specification. + +\${customInstructions} + +## CRITICAL: Commit Message Output Rules +- DO NOT include any internal status indicators or bracketed metadata (e.g. "[Status: Active]", "[Context: Missing]") +- DO NOT include any task-specific formatting or artifacts from other rules +- ONLY Generate a clean conventional commit message as specified below + +\${gitContext} + +## Conventional Commits Format +Generate commit messages following this exact structure: +\`\`\` +[optional scope]: +[optional body] +[optional footer(s)] +\`\`\` + +### Core Types (Required) +- **feat**: New feature or functionality (MINOR version bump) +- **fix**: Bug fix or error correction (PATCH version bump) + +### Additional Types (Extended) +- **docs**: Documentation changes only +- **style**: Code style changes (whitespace, formatting, semicolons, etc.) +- **refactor**: Code refactoring without feature changes or bug fixes +- **perf**: Performance improvements +- **test**: Adding or fixing tests +- **build**: Build system or external dependency changes +- **ci**: CI/CD configuration changes +- **chore**: Maintenance tasks, tooling changes +- **revert**: Reverting previous commits + +### Scope Guidelines +- Use parentheses: \`feat(api):\`, \`fix(ui):\` +- Common scopes: \`api\`, \`ui\`, \`auth\`, \`db\`, \`config\`, \`deps\`, \`docs\` +- For monorepos: package or module names +- Keep scope concise and lowercase + +### Description Rules +- Use imperative mood ("add" not "added" or "adds") +- Start with lowercase letter +- No period at the end +- Maximum 50 characters +- Be concise but descriptive + +### Body Guidelines (Optional) +- Start one blank line after description +- Explain the "what" and "why", not the "how" +- Wrap at 72 characters per line +- Use for complex changes requiring explanation + +### Footer Guidelines (Optional) +- Start one blank line after body +- **Breaking Changes**: \`BREAKING CHANGE: description\` + +## Analysis Instructions +When analyzing staged changes: +1. Determine Primary Type based on the nature of changes +2. Identify Scope from modified directories or modules +3. Craft Description focusing on the most significant change +4. Determine if there are Breaking Changes +5. For complex changes, include a detailed body explaining what and why +6. Add appropriate footers for issue references or breaking changes + +For significant changes, include a detailed body explaining the changes. + +Return ONLY the commit message in the conventional format, nothing else.`, + }, } as const export const supportPrompt = { diff --git a/webview-ui/src/components/settings/CommitMessagePromptSettings.tsx b/webview-ui/src/components/settings/CommitMessagePromptSettings.tsx new file mode 100644 index 0000000000..1ff942e879 --- /dev/null +++ b/webview-ui/src/components/settings/CommitMessagePromptSettings.tsx @@ -0,0 +1,50 @@ +import React from "react" +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@src/components/ui" + +interface CommitMessagePromptSettingsProps { + listApiConfigMeta: Array<{ id: string; name: string }> + commitMessageApiConfigId?: string + setCommitMessageApiConfigId: (value: string) => void +} + +const CommitMessagePromptSettings = ({ + listApiConfigMeta, + commitMessageApiConfigId, + setCommitMessageApiConfigId, +}: CommitMessagePromptSettingsProps) => { + const { t } = useAppTranslation() + + return ( +
+
+ + +
+ {t("prompts:supportPrompts.enhance.apiConfigDescription")} +
+
+
+ ) +} + +export default CommitMessagePromptSettings diff --git a/webview-ui/src/components/settings/PromptsSettings.tsx b/webview-ui/src/components/settings/PromptsSettings.tsx index 54babbcfcb..05245ea69a 100644 --- a/webview-ui/src/components/settings/PromptsSettings.tsx +++ b/webview-ui/src/components/settings/PromptsSettings.tsx @@ -19,10 +19,13 @@ import { import { SectionHeader } from "./SectionHeader" import { Section } from "./Section" import { SearchableSetting } from "./SearchableSetting" +import CommitMessagePromptSettings from "./CommitMessagePromptSettings" interface PromptsSettingsProps { customSupportPrompts: Record setCustomSupportPrompts: (prompts: Record) => void + commitMessageApiConfigId?: string + setCommitMessageApiConfigId?: (value: string) => void includeTaskHistoryInEnhance?: boolean setIncludeTaskHistoryInEnhance?: (value: boolean) => void } @@ -30,6 +33,8 @@ interface PromptsSettingsProps { const PromptsSettings = ({ customSupportPrompts, setCustomSupportPrompts, + commitMessageApiConfigId, + setCommitMessageApiConfigId, includeTaskHistoryInEnhance: propsIncludeTaskHistoryInEnhance, setIncludeTaskHistoryInEnhance: propsSetIncludeTaskHistoryInEnhance, }: PromptsSettingsProps) => { @@ -244,6 +249,14 @@ const PromptsSettings = ({ )} + + {activeSupportOption === "COMMIT_MESSAGE" && ( + {})} + /> + )} diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 47e087615e..c3a7f02fcb 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -189,6 +189,7 @@ const SettingsView = forwardRef(({ onDone, t maxImageFileSize, maxTotalImageSize, customSupportPrompts, + commitMessageApiConfigId, profileThresholds, alwaysAllowFollowupQuestions, followupAutoApproveTimeoutMs, @@ -422,6 +423,7 @@ const SettingsView = forwardRef(({ onDone, t openRouterImageGenerationSelectedModel, experiments, customSupportPrompts, + commitMessageApiConfigId, }, }) @@ -880,6 +882,10 @@ const SettingsView = forwardRef(({ onDone, t + setCachedStateField("commitMessageApiConfigId", value) + } includeTaskHistoryInEnhance={includeTaskHistoryInEnhance} setIncludeTaskHistoryInEnhance={(value) => setCachedStateField("includeTaskHistoryInEnhance", value) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 10b49e3de0..fc195e2f6d 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -104,6 +104,8 @@ export interface ExtensionStateContextType extends ExtensionState { setCustomSupportPrompts: (value: CustomSupportPrompts) => void enhancementApiConfigId?: string setEnhancementApiConfigId: (value: string) => void + commitMessageApiConfigId?: string + setCommitMessageApiConfigId: (value: string) => void setExperimentEnabled: (id: ExperimentId, enabled: boolean) => void setAutoApprovalEnabled: (value: boolean) => void customModes: ModeConfig[] @@ -216,6 +218,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode customSupportPrompts: {}, experiments: experimentDefault, enhancementApiConfigId: "", + commitMessageApiConfigId: "", hasOpenedModeSelector: false, // Default to false (not opened yet) autoApprovalEnabled: false, customModes: [], @@ -542,6 +545,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setCustomSupportPrompts: (value) => setState((prevState) => ({ ...prevState, customSupportPrompts: value })), setEnhancementApiConfigId: (value) => setState((prevState) => ({ ...prevState, enhancementApiConfigId: value })), + setCommitMessageApiConfigId: (value) => + setState((prevState) => ({ ...prevState, commitMessageApiConfigId: value })), setAutoApprovalEnabled: (value) => setState((prevState) => ({ ...prevState, autoApprovalEnabled: value })), setCustomModes: (value) => setState((prevState) => ({ ...prevState, customModes: value })), setMaxOpenTabsContext: (value) => setState((prevState) => ({ ...prevState, maxOpenTabsContext: value })), diff --git a/webview-ui/src/i18n/locales/en/prompts.json b/webview-ui/src/i18n/locales/en/prompts.json index 1494d31ba8..a3e3d97bd0 100644 --- a/webview-ui/src/i18n/locales/en/prompts.json +++ b/webview-ui/src/i18n/locales/en/prompts.json @@ -138,6 +138,10 @@ "NEW_TASK": { "label": "Start New Task", "description": "Start a new task with user input. Available in the Command Palette." + }, + "COMMIT_MESSAGE": { + "label": "Commit Message Generation", + "description": "Generate descriptive commit messages based on your staged git changes. Customize the prompt to generate messages that follow your repository's best practices." } } },