From a9fa6916eecc264a8b561362d631b4867515c6df Mon Sep 17 00:00:00 2001 From: Mirrowel <28632877+Mirrowel@users.noreply.github.com> Date: Sat, 16 May 2026 04:24:38 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(scm):=20=E2=9C=A8=20add=20AI=20commit?= =?UTF-8?q?=20message=20generator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a new feature that automatically analyzes staged Git changes and generates conventional commit messages using AI. - Register the `zoo-code.generateCommitMessage` command and integrate it into the VS Code Source Control (SCM) view. - Add `commitMessageApiConfigId` to global state, allowing users to configure a dedicated API provider for commit generation. - Introduce a `COMMIT_MESSAGE` support prompt with a detailed default template optimized for conventional commits and Gitmoji. - Add UI settings for customizing the commit message prompt. - Track `COMMIT_MSG_GENERATED` events in telemetry. - Update build types to support theme-aware (light/dark) icons for extension commands. --- packages/build/src/types.ts | 2 +- packages/types/src/global-settings.ts | 2 + packages/types/src/telemetry.ts | 3 + packages/types/src/vscode-extension-host.ts | 2 + src/core/webview/ClineProvider.ts | 3 + src/core/webview/webviewMessageHandler.ts | 4 + src/extension.ts | 9 + src/i18n/locales/en/common.json | 50 +++ src/package.json | 21 ++ src/package.nls.json | 1 + .../commit-message/CommitMessageGenerator.ts | 167 +++++++++ .../CommitMessageOrchestrator.ts | 121 +++++++ .../commit-message/CommitMessageProvider.ts | 61 ++++ .../commit-message/GitExtensionService.ts | 339 ++++++++++++++++++ .../adapters/ICommitMessageAdapter.ts | 5 + .../adapters/ICommitMessageIntegration.ts | 10 + .../adapters/VSCodeCommitMessageAdapter.ts | 108 ++++++ src/services/commit-message/exclusionUtils.ts | 125 +++++++ src/services/commit-message/index.ts | 18 + src/services/commit-message/types.ts | 23 ++ src/services/commit-message/types/core.ts | 43 +++ src/services/commit-message/types/vscode.ts | 12 + src/shared/support-prompt.ts | 74 ++++ .../settings/CommitMessagePromptSettings.tsx | 47 +++ .../components/settings/PromptsSettings.tsx | 3 + .../src/context/ExtensionStateContext.tsx | 5 + webview-ui/src/i18n/locales/en/prompts.json | 4 + 27 files changed, 1261 insertions(+), 1 deletion(-) create mode 100644 src/services/commit-message/CommitMessageGenerator.ts create mode 100644 src/services/commit-message/CommitMessageOrchestrator.ts create mode 100644 src/services/commit-message/CommitMessageProvider.ts create mode 100644 src/services/commit-message/GitExtensionService.ts create mode 100644 src/services/commit-message/adapters/ICommitMessageAdapter.ts create mode 100644 src/services/commit-message/adapters/ICommitMessageIntegration.ts create mode 100644 src/services/commit-message/adapters/VSCodeCommitMessageAdapter.ts create mode 100644 src/services/commit-message/exclusionUtils.ts create mode 100644 src/services/commit-message/index.ts create mode 100644 src/services/commit-message/types.ts create mode 100644 src/services/commit-message/types/core.ts create mode 100644 src/services/commit-message/types/vscode.ts create mode 100644 webview-ui/src/components/settings/CommitMessagePromptSettings.tsx 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..65c22daf09 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" @@ -490,6 +491,7 @@ export interface WebviewMessage { | "copySystemPrompt" | "systemPrompt" | "enhancementApiConfigId" + | "commitMessageApiConfigId" | "autoApprovalEnabled" | "updateCustomMode" | "deleteCustomMode" 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..f296224c34 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1606,6 +1606,10 @@ export const webviewMessageHandler = async ( await updateGlobalState("enhancementApiConfigId", message.text) await provider.postStateToWebview() break + case "commitMessageApiConfigId": + await updateGlobalState("commitMessageApiConfigId", message.text) + await provider.postStateToWebview() + break case "autoApprovalEnabled": await updateGlobalState("autoApprovalEnabled", message.bool ?? false) 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..4c28faa5bf 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -259,5 +259,55 @@ "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}}", + "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..5c6afc94cc --- /dev/null +++ b/src/services/commit-message/CommitMessageGenerator.ts @@ -0,0 +1,167 @@ +import { ContextProxy } from "../../core/config/ContextProxy" +import { ProviderSettingsManager } from "../../core/config/ProviderSettingsManager" +import { singleCompletionHandler } from "../../utils/single-completion-handler" +import { supportPrompt } from "../../shared/support-prompt" +import { addCustomInstructions } 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 class CommitMessageGenerator { + private readonly providerSettingsManager: ProviderSettingsManager + private previousGitContext: string | null = null + private previousCommitMessage: string | null = null + + constructor(providerSettingsManager: ProviderSettingsManager) { + this.providerSettingsManager = providerSettingsManager + } + + 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 + + TelemetryService.instance.captureEvent(TelemetryEventName.COMMIT_MSG_GENERATED) + + 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 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 = ContextProxy.instance + 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") + const listApiConfigMeta = contextProxy.getValue("listApiConfigMeta") || [] + const customSupportPrompts = contextProxy.getValue("customSupportPrompts") || {} + + let configToUse: ProviderSettings = apiConfiguration + + if ( + commitMessageApiConfigId && + listApiConfigMeta.find(({ id }: { id: string }) => id === commitMessageApiConfigId) + ) { + try { + await this.providerSettingsManager.initialize() + const { name: _, ...providerSettings } = await this.providerSettingsManager.getProfile({ + id: commitMessageApiConfigId, + }) + + if (providerSettings.apiProvider) { + configToUse = providerSettings + } + } catch (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 singleCompletionHandler(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/CommitMessageOrchestrator.ts b/src/services/commit-message/CommitMessageOrchestrator.ts new file mode 100644 index 0000000000..40d5b5f6dd --- /dev/null +++ b/src/services/commit-message/CommitMessageOrchestrator.ts @@ -0,0 +1,121 @@ +import * as path from "path" +import { CommitMessageRequest, CommitMessageResult } from "./types/core" +import { GitExtensionService, GitChange } from "./GitExtensionService" +import { CommitMessageGenerator } from "./CommitMessageGenerator" +import { ICommitMessageIntegration } from "./adapters/ICommitMessageIntegration" +import { t } from "../../i18n" +import { GitStatus } from "./types" + +export interface ChangeResolution { + changes: GitChange[] + files: string[] + usedStaged: boolean +} + +export class CommitMessageOrchestrator { + async generateCommitMessage( + request: CommitMessageRequest, + integration: ICommitMessageIntegration, + messageGenerator: CommitMessageGenerator, + ): Promise { + let gitService: GitExtensionService | null = null + + try { + integration.reportProgress?.(5, t("common:commitMessage.initializing")) + gitService = new GitExtensionService(request.workspacePath) + + integration.reportProgress?.(15, t("common:commitMessage.discoveringFiles")) + const resolution = await this.resolveCommitChanges(gitService, request.selectedFiles, integration) + + if (resolution.changes.length === 0) { + const result = { message: "", error: "No changes found" } + await integration.handleResult(result) + return result + } + + integration.reportProgress?.( + 25, + t("common:commitMessage.foundChanges", { count: resolution.changes.length }), + ) + + if (!resolution.usedStaged && resolution.files.length > 0) { + integration.showMessage?.("Generating commit message from unstaged changes", "info") + } + + integration.reportProgress?.(40, t("common:commitMessage.gettingContext")) + + const gitContext = await gitService.getCommitContext( + resolution.changes, + { staged: resolution.usedStaged, includeRepoContext: true }, + resolution.files, + ) + + integration.reportProgress?.(70, t("common:commitMessage.generating")) + + const message = await messageGenerator.generateMessage({ + workspacePath: request.workspacePath, + selectedFiles: resolution.files, + gitContext, + onProgress: (update) => { + if (update.percentage !== undefined) { + const scaledPercentage = 70 + update.percentage * 0.25 + integration.reportProgress?.(scaledPercentage, update.message) + } + }, + }) + + const result = { message } + await integration.handleResult(result) + + integration.reportProgress?.(100, t("common:commitMessage.generated")) + return result + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred" + const result = { message: "", error: errorMessage } + + await integration.showMessage?.(errorMessage, "error") + await integration.handleResult(result) + + return result + } finally { + gitService?.dispose() + } + } + + private async resolveCommitChanges( + gitService: GitExtensionService, + selectedFiles?: string[], + integration?: ICommitMessageIntegration, + ): Promise { + if (selectedFiles && selectedFiles.length > 0) { + const changes: GitChange[] = selectedFiles.map((filePath) => { + const status: GitStatus = "M" + const staged = false + return { + filePath, + status, + staged, + } + }) + return { + changes, + files: selectedFiles, + usedStaged: false, + } + } + + let changes = await gitService.gatherChanges({ staged: true }) + let usedStaged = true + + if (changes.length === 0) { + changes = await gitService.gatherChanges({ staged: false }) + usedStaged = false + } + + return { + changes, + files: changes.map((change) => change.filePath), + usedStaged, + } + } +} diff --git a/src/services/commit-message/CommitMessageProvider.ts b/src/services/commit-message/CommitMessageProvider.ts new file mode 100644 index 0000000000..f063576a7c --- /dev/null +++ b/src/services/commit-message/CommitMessageProvider.ts @@ -0,0 +1,61 @@ +import * as vscode from "vscode" +import { ProviderSettingsManager } from "../../core/config/ProviderSettingsManager" +import { t } from "../../i18n" +import { Package } from "../../shared/package" + +import { CommitMessageRequest, CommitMessageResult } from "./types/core" +import { CommitMessageGenerator } from "./CommitMessageGenerator" +import { VSCodeCommitMessageAdapter } from "./adapters/VSCodeCommitMessageAdapter" +import { VscGenerationRequest } from "./types" + +export class CommitMessageProvider implements vscode.Disposable { + private generator: CommitMessageGenerator + private vscodeAdapter: VSCodeCommitMessageAdapter + + constructor( + private context: vscode.ExtensionContext, + private outputChannel: vscode.OutputChannel, + ) { + const providerSettingsManager = new ProviderSettingsManager(this.context) + + this.generator = new CommitMessageGenerator(providerSettingsManager) + this.vscodeAdapter = new VSCodeCommitMessageAdapter(this.generator) + } + + 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 { + const request: CommitMessageRequest = { + workspacePath: this.determineWorkspacePath(vsRequest?.rootUri), + } + + await this.vscodeAdapter.generateCommitMessage(request) + } + + 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 { + this.vscodeAdapter?.dispose() + } +} diff --git a/src/services/commit-message/GitExtensionService.ts b/src/services/commit-message/GitExtensionService.ts new file mode 100644 index 0000000000..002dfa3e21 --- /dev/null +++ b/src/services/commit-message/GitExtensionService.ts @@ -0,0 +1,339 @@ +import * as vscode from "vscode" +import * as path from "path" +import { spawnSync } from "child_process" +import { shouldExcludeLockFile } from "./exclusionUtils" +import { RooIgnoreController } from "../../core/ignore/RooIgnoreController" +import { GitProgressOptions, GitChange, GitOptions, GitStatus } from "./types" + +export type { GitChange, GitOptions, GitProgressOptions } from "./types" + +export class GitExtensionService { + private ignoreController: RooIgnoreController | null = null + + constructor(private workspaceRoot: string) { + try { + this.ignoreController = new RooIgnoreController(workspaceRoot) + this.ignoreController.initialize() + } catch (error) { + this.ignoreController = null + } + } + + public async gatherChanges(options: GitProgressOptions): Promise { + try { + const statusOutput = this.getStatus(options) + if (!statusOutput.trim()) { + return [] + } + + const changes: GitChange[] = [] + const lines = statusOutput.split("\n").filter((line: string) => line.trim()) + + for (const line of lines) { + if (!line || line.length < 2) continue + + let statusCode: string + let filePath: string + + if (options.staged) { + const tabIndex = line.indexOf("\t") + if (tabIndex > 0) { + statusCode = line.substring(0, tabIndex).trim() + filePath = line.substring(tabIndex + 1).trim() + } else { + continue + } + } else { + if (line.length < 3) continue + + const indexStatus = line.charAt(0) + const workingStatus = line.charAt(1) + filePath = line.substring(2).trim() + + if (workingStatus !== " ") { + statusCode = workingStatus + } else if (indexStatus !== " ") { + statusCode = indexStatus + } else { + continue + } + + if (indexStatus === "?" && workingStatus === "?") { + statusCode = "?" + } + } + + if (filePath && statusCode) { + changes.push({ + filePath: path.join(this.workspaceRoot, filePath), + status: this.getChangeStatusFromCode(statusCode), + staged: options.staged, + }) + } + } + + return changes + } catch (error) { + return [] + } + } + + public spawnGitWithArgs(args: string[]): string { + const result = spawnSync("git", args, { + cwd: this.workspaceRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }) + + if (result.error) { + throw result.error + } + + if (result.status !== 0) { + throw new Error(`Git command failed with status ${result.status}: ${result.stderr}`) + } + + return result.stdout + } + + private async getDiffForChanges(changes: GitChange[], options: GitProgressOptions): Promise { + const { onProgress } = options || {} + if (changes.length === 0) { + return "" + } + + try { + const diffs: string[] = [] + let processedFiles = 0 + + for (const change of changes) { + const relativePath = path.relative(this.workspaceRoot, change.filePath) + + if (this.shouldIncludeFile(relativePath)) { + const stagedFlag = change.staged ?? options.staged ?? true + const diff = this.getGitDiff(change.filePath, { staged: stagedFlag }) + + if (diff) { + diffs.push(diff) + } + } + + processedFiles++ + this.reportProgress(onProgress, processedFiles, changes.length) + } + + return diffs.join("\n") + } catch (error) { + return "" + } + } + + private getStatus(options: GitOptions): string { + const { staged } = options + if (staged) { + return this.spawnGitWithArgs(["diff", "--name-status", "--cached"]) + } else { + return this.spawnGitWithArgs(["status", "--porcelain"]) + } + } + + private getGitDiff(filePath: string, options: GitOptions): string { + const { staged } = options + + try { + if (this.isBinaryFile(filePath, staged)) { + return `Binary file ${filePath} has been ${staged ? "staged" : "modified"}` + } + + if (!staged && this.isUntrackedFile(filePath)) { + return `New untracked file: ${filePath}` + } + + const diffArgs = this.buildDiffArgs(staged, filePath) + return this.spawnGitWithArgs(diffArgs) + } catch (error) { + return `File ${filePath} - diff unavailable` + } + } + + private getCurrentBranch(): string { + return this.spawnGitWithArgs(["branch", "--show-current"]) + } + + private getRecentCommits(count: number = 5): string { + return this.spawnGitWithArgs(["log", "--oneline", `-${count}`]) + } + + public async getCommitContext( + changes: GitChange[], + options: GitProgressOptions, + specificFiles?: string[], + ): Promise { + const { staged, includeRepoContext = true } = options + + try { + let context = "## Git Context for Commit Message Generation\n\n" + + const targetChanges = + specificFiles && specificFiles.length > 0 + ? changes.filter((change) => { + const absolutePath = change.filePath + const relativePath = path.relative(this.workspaceRoot, absolutePath) + return specificFiles.some( + (file) => + file === absolutePath || + file === relativePath || + absolutePath.endsWith(file) || + relativePath === path.normalize(file), + ) + }) + : changes + + try { + const diff = await this.getDiffForChanges(targetChanges, options) + 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" + context += `### Full Diff of ${changeDescriptor} Changes${fileInfo}\n\`\`\`diff\n` + diff + "\n```\n\n" + } catch (error) { + const changeType = staged ? "Staged" : "Unstaged" + const fileInfo = specificFiles ? ` (${specificFiles.length} selected files)` : "" + context += `### Full Diff of ${changeType} Changes${fileInfo}\n\`\`\`diff\n(No diff available)\n\`\`\`\n\n` + } + + if (targetChanges.length > 0) { + const summaryLines = targetChanges.map((change) => { + const relativePath = path.relative(this.workspaceRoot, change.filePath) + const scope = change.staged ? "staged" : "unstaged" + return `${this.getReadableStatus(change.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 = this.getCurrentBranch() + if (currentBranch) { + context += "**Current branch:** `" + currentBranch.trim() + "`\n\n" + } + } catch (error) {} + + try { + const recentCommits = this.getRecentCommits() + if (recentCommits) { + context += "**Recent commits:**\n```\n" + recentCommits + "\n```\n" + } + } catch (error) {} + } + + return context + } catch (error) { + return "## Error generating commit context\n\nUnable to gather complete context for commit message generation." + } + } + + private getChangeStatusFromCode(code: string): GitStatus { + switch (code) { + case "M": + case "A": + case "D": + case "R": + case "C": + case "U": + case "?": + return code 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" + } + } + + private shouldIncludeFile(relativePath: string): boolean { + let isValidFile = true + if (this.ignoreController) { + try { + isValidFile = this.ignoreController.validateAccess(relativePath) + } catch (error) { + isValidFile = true + } + } + return isValidFile && !shouldExcludeLockFile(relativePath) + } + + private reportProgress( + onProgress: ((percentage: number) => void) | undefined, + processed: number, + total: number, + ): void { + if (onProgress && total > 0) { + const percentage = (processed / total) * 100 + onProgress(percentage) + } + } + + private isBinaryFile(filePath: string, staged: boolean): boolean { + try { + const checkArgs = this.buildNumstatArgs(staged, filePath) + const numstatOutput = this.spawnGitWithArgs(checkArgs) + return numstatOutput.includes("-\t-\t") + } catch (error) { + return false + } + } + + private isUntrackedFile(filePath: string): boolean { + try { + const statusArgs = ["status", "--porcelain", "--", filePath] + const statusOutput = this.spawnGitWithArgs(statusArgs) + return statusOutput.startsWith("??") + } catch (error) { + return false + } + } + + private buildNumstatArgs(staged: boolean, filePath: string): string[] { + return staged ? ["diff", "--cached", "--numstat", "--", filePath] : ["diff", "--numstat", "--", filePath] + } + + private buildDiffArgs(staged: boolean, filePath: string): string[] { + return staged ? ["diff", "--cached", "--", filePath] : ["diff", "--", filePath] + } + + public dispose(): void { + try { + if (this.ignoreController) { + this.ignoreController.dispose() + } + } catch (error) { + } finally { + this.ignoreController = null + } + } +} diff --git a/src/services/commit-message/adapters/ICommitMessageAdapter.ts b/src/services/commit-message/adapters/ICommitMessageAdapter.ts new file mode 100644 index 0000000000..53932344a4 --- /dev/null +++ b/src/services/commit-message/adapters/ICommitMessageAdapter.ts @@ -0,0 +1,5 @@ +import { CommitMessageRequest, CommitMessageResult } from "../types/core" + +export interface ICommitMessageAdapter { + generateCommitMessage(request: CommitMessageRequest): Promise +} diff --git a/src/services/commit-message/adapters/ICommitMessageIntegration.ts b/src/services/commit-message/adapters/ICommitMessageIntegration.ts new file mode 100644 index 0000000000..2fb386db05 --- /dev/null +++ b/src/services/commit-message/adapters/ICommitMessageIntegration.ts @@ -0,0 +1,10 @@ +import { GitChange } from "../GitExtensionService" +import { CommitMessageResult } from "../types/core" + +export interface ICommitMessageIntegration { + reportProgress?(percentage: number, message?: string): void + + showMessage?(message: string, type: "info" | "error" | "warning"): Promise + + handleResult(result: CommitMessageResult): Promise +} diff --git a/src/services/commit-message/adapters/VSCodeCommitMessageAdapter.ts b/src/services/commit-message/adapters/VSCodeCommitMessageAdapter.ts new file mode 100644 index 0000000000..772a35cd35 --- /dev/null +++ b/src/services/commit-message/adapters/VSCodeCommitMessageAdapter.ts @@ -0,0 +1,108 @@ +import * as vscode from "vscode" +import { ICommitMessageAdapter } from "./ICommitMessageAdapter" +import { ICommitMessageIntegration } from "./ICommitMessageIntegration" +import { CommitMessageRequest, CommitMessageResult, MessageType } from "../types/core" +import { VscGenerationRequest, VSCodeMessageTypeMap } from "../types/vscode" +import { t } from "../../../i18n" +import { CommitMessageGenerator } from "../CommitMessageGenerator" +import { CommitMessageOrchestrator } from "../CommitMessageOrchestrator" + +export class VSCodeCommitMessageAdapter implements ICommitMessageAdapter { + private targetRepository: VscGenerationRequest | null = null + private orchestrator: CommitMessageOrchestrator + + constructor(private messageGenerator: CommitMessageGenerator) { + this.orchestrator = new CommitMessageOrchestrator() + } + + async generateCommitMessage(request: CommitMessageRequest): Promise { + try { + const targetRepository = await this.determineTargetRepository(request.workspacePath) + if (!targetRepository?.rootUri) { + throw new Error("Could not determine Git repository") + } + this.targetRepository = targetRepository + + return await vscode.window.withProgress( + { + location: vscode.ProgressLocation.SourceControl, + title: t("common:commitMessage.generating"), + cancellable: false, + }, + async (progress) => { + const integration: ICommitMessageIntegration = { + reportProgress: (percentage: number, message?: string) => { + progress.report({ + increment: Math.max(0, percentage - (progress as any)._lastPercentage || 0), + message: message || t("common:commitMessage.generating"), + }) + ;(progress as any)._lastPercentage = percentage + }, + + showMessage: async (message: string, type: MessageType) => { + const methodName = VSCodeMessageTypeMap[type] + const method = vscode.window[methodName] as ( + message: string, + ) => Thenable + await method(message) + }, + + handleResult: async (result: CommitMessageResult) => { + if (result.message && this.targetRepository) { + this.targetRepository.inputBox.value = result.message + } + if (result.error) { + const methodName = VSCodeMessageTypeMap["error"] + const method = vscode.window[methodName] as ( + message: string, + ) => Thenable + await method(t("common:commitMessage.generationFailed", { errorMessage: result.error })) + } + }, + } + + return this.orchestrator.generateCommitMessage(request, integration, this.messageGenerator) + }, + ) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred" + return { message: "", error: errorMessage } + } + } + + private async determineTargetRepository(workspacePath: string): Promise { + try { + const gitExtension = vscode.extensions.getExtension("vscode.git") + if (!gitExtension) { + return null + } + + if (!gitExtension.isActive) { + try { + await gitExtension.activate() + } catch (activationError) { + return null + } + } + + 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 + } + } + + public dispose(): void { + this.targetRepository = null + } +} diff --git a/src/services/commit-message/exclusionUtils.ts b/src/services/commit-message/exclusionUtils.ts new file mode 100644 index 0000000000..5d66421228 --- /dev/null +++ b/src/services/commit-message/exclusionUtils.ts @@ -0,0 +1,125 @@ +import ignore, { Ignore } from "ignore" +import { normalize } from "path" + +const lockFiles: string[] = [ + "package-lock.json", + "npm-shrinkwrap.json", + "yarn.lock", + "pnpm-lock.yaml", + "pnpm-workspace.yaml", + "bun.lockb", + ".yarnrc.yml", + ".pnp.js", + ".pnp.cjs", + "jspm.lock", + + "Pipfile.lock", + "poetry.lock", + "pdm.lock", + ".pdm-lock.toml", + "conda-lock.yml", + "pylock.toml", + + "Gemfile.lock", + ".bundle/config", + + "composer.lock", + + "gradle.lockfile", + "lockfile.json", + "dependency-lock.json", + "dependency-reduced-pom.xml", + "coursier.lock", + + "build.sbt.lock", + + "packages.lock.json", + "paket.lock", + "project.assets.json", + + "Cargo.lock", + + "go.sum", + "Gopkg.lock", + "glide.lock", + "vendor/vendor.json", + + "build.zig.zon.lock", + + "dune.lock", + "opam.lock", + + "kotlin-js-store", + + "Package.resolved", + "Podfile.lock", + "Cartfile.resolved", + + "pubspec.lock", + + "mix.lock", + "rebar.lock", + + "stack.yaml.lock", + "cabal.project.freeze", + + "elm-stuff/exact-dependencies.json", + + "shard.lock", + + "Manifest.toml", + "JuliaManifest.toml", + + "renv.lock", + "packrat.lock", + + "nimble.lock", + + "dub.selections.json", + + "rocks.lock", + + "carton.lock", + "cpanfile.snapshot", + + "conan.lock", + "vcpkg-lock.json", + + ".terraform.lock.hcl", + "Berksfile.lock", + "Puppetfile.lock", + + "flake.lock", + + "deno.lock", + + "devcontainer.lock.json", +] + +const createLockFileIgnoreInstance = (): Ignore => { + const ignoreInstance = ignore() + + const lockFilePatterns = lockFiles.map((file) => `**/${file}`) + ignoreInstance.add(lockFilePatterns) + + const directoryPatterns = [ + "**/kotlin-js-store", + "**/kotlin-js-store/**", + "**/elm-stuff", + "**/elm-stuff/**", + "**/.yarn/cache/**", + "**/.yarn/unplugged/**", + "**/.yarn/build-state.yml", + "**/.yarn/install-state.gz", + ] + ignoreInstance.add(directoryPatterns) + + return ignoreInstance +} + +const lockFileIgnoreInstance = createLockFileIgnoreInstance() + +export function shouldExcludeLockFile(filePath: string): boolean { + const normalizedPath = normalize(filePath) + return lockFileIgnoreInstance.ignores(normalizedPath) +} 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.ts b/src/services/commit-message/types.ts new file mode 100644 index 0000000000..6cd996cac4 --- /dev/null +++ b/src/services/commit-message/types.ts @@ -0,0 +1,23 @@ +import * as vscode from "vscode" + +export type GitStatus = "M" | "A" | "D" | "R" | "C" | "U" | "?" | "Unknown" + +export interface GitChange { + filePath: string + status: GitStatus + staged: boolean +} + +export interface GitOptions { + staged: boolean +} + +export interface GitProgressOptions extends GitOptions { + onProgress?: (percentage: number) => void + includeRepoContext?: boolean +} + +export interface VscGenerationRequest { + inputBox: { value: string } + rootUri?: vscode.Uri +} diff --git a/src/services/commit-message/types/core.ts b/src/services/commit-message/types/core.ts new file mode 100644 index 0000000000..039bf2b0ce --- /dev/null +++ b/src/services/commit-message/types/core.ts @@ -0,0 +1,43 @@ +export interface CommitMessageRequest { + workspacePath: string + selectedFiles?: string[] +} + +export interface CommitMessageResult { + message: string + error?: string +} + +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 +} + +export interface ProgressTask { + execute: (progress: ProgressReporter) => Promise + title: string + location: ProgressLocation + cancellable?: boolean +} + +export interface ProgressReporter { + report(value: { message?: string; increment?: number }): void +} + +export type MessageType = "info" | "error" | "warning" + +export type ProgressLocation = "SourceControl" | "Notification" | "Window" diff --git a/src/services/commit-message/types/vscode.ts b/src/services/commit-message/types/vscode.ts new file mode 100644 index 0000000000..7949f61bd6 --- /dev/null +++ b/src/services/commit-message/types/vscode.ts @@ -0,0 +1,12 @@ +import * as vscode from "vscode" + +export interface VscGenerationRequest { + inputBox: { value: string } + rootUri?: vscode.Uri +} + +export const VSCodeMessageTypeMap = { + info: "showInformationMessage", + error: "showErrorMessage", + warning: "showWarningMessage", +} as const 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..674233d86b --- /dev/null +++ b/webview-ui/src/components/settings/CommitMessagePromptSettings.tsx @@ -0,0 +1,47 @@ +import React from "react" +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { useExtensionState } from "@src/context/ExtensionStateContext" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@src/components/ui" +import { vscode } from "@src/utils/vscode" + +const CommitMessagePromptSettings = () => { + const { t } = useAppTranslation() + const { listApiConfigMeta, commitMessageApiConfigId, setCommitMessageApiConfigId } = useExtensionState() + + 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..5c94e709dd 100644 --- a/webview-ui/src/components/settings/PromptsSettings.tsx +++ b/webview-ui/src/components/settings/PromptsSettings.tsx @@ -19,6 +19,7 @@ import { import { SectionHeader } from "./SectionHeader" import { Section } from "./Section" import { SearchableSetting } from "./SearchableSetting" +import CommitMessagePromptSettings from "./CommitMessagePromptSettings" interface PromptsSettingsProps { customSupportPrompts: Record @@ -244,6 +245,8 @@ const PromptsSettings = ({ )} + + {activeSupportOption === "COMMIT_MESSAGE" && } 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." } } }, From 6f629466e177e0a4be9b605864b22f49b779cdb1 Mon Sep 17 00:00:00 2001 From: Mirrowel <28632877+Mirrowel@users.noreply.github.com> Date: Sat, 16 May 2026 05:07:39 +0200 Subject: [PATCH 2/4] =?UTF-8?q?refactor(scm):=20=F0=9F=94=A8=20simplify=20?= =?UTF-8?q?commit=20message=20architecture=20and=20use=20async=20git=20ope?= =?UTF-8?q?rations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit refactors the AI commit message generator to reduce complexity and prevent blocking the VS Code extension host during Git commands. - Rewrite `GitExtensionService` to use asynchronous `child_process.execFile` instead of synchronous `spawnSync`. - Remove abstraction layers (`CommitMessageOrchestrator`, `VSCodeCommitMessageAdapter`) and handle VS Code UI progress natively in `CommitMessageProvider`. - Drop custom ignore logic and lockfile exclusions in favor of Git's native status parsing (`--name-status` and `--porcelain`). - Centralize `commitMessageApiConfigId` state updates in `SettingsView`, removing the direct `vscode.postMessage` handler. - Add unit tests for the newly refactored `GitExtensionService`. --- packages/types/src/vscode-extension-host.ts | 1 - src/core/webview/webviewMessageHandler.ts | 5 - .../CommitMessageOrchestrator.ts | 121 ----- .../commit-message/CommitMessageProvider.ts | 136 +++++- .../commit-message/GitExtensionService.ts | 449 ++++++++---------- .../__tests__/GitExtensionService.spec.ts | 81 ++++ .../adapters/ICommitMessageAdapter.ts | 5 - .../adapters/ICommitMessageIntegration.ts | 10 - .../adapters/VSCodeCommitMessageAdapter.ts | 108 ----- src/services/commit-message/exclusionUtils.ts | 125 ----- src/services/commit-message/types.ts | 8 +- src/services/commit-message/types/core.ts | 25 - src/services/commit-message/types/vscode.ts | 12 - .../settings/CommitMessagePromptSettings.tsx | 19 +- .../components/settings/PromptsSettings.tsx | 12 +- .../src/components/settings/SettingsView.tsx | 6 + 16 files changed, 438 insertions(+), 685 deletions(-) delete mode 100644 src/services/commit-message/CommitMessageOrchestrator.ts create mode 100644 src/services/commit-message/__tests__/GitExtensionService.spec.ts delete mode 100644 src/services/commit-message/adapters/ICommitMessageAdapter.ts delete mode 100644 src/services/commit-message/adapters/ICommitMessageIntegration.ts delete mode 100644 src/services/commit-message/adapters/VSCodeCommitMessageAdapter.ts delete mode 100644 src/services/commit-message/exclusionUtils.ts delete mode 100644 src/services/commit-message/types/vscode.ts diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 65c22daf09..5d949443d2 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -491,7 +491,6 @@ export interface WebviewMessage { | "copySystemPrompt" | "systemPrompt" | "enhancementApiConfigId" - | "commitMessageApiConfigId" | "autoApprovalEnabled" | "updateCustomMode" | "deleteCustomMode" diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index f296224c34..22165016f2 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1606,11 +1606,6 @@ export const webviewMessageHandler = async ( await updateGlobalState("enhancementApiConfigId", message.text) await provider.postStateToWebview() break - case "commitMessageApiConfigId": - await updateGlobalState("commitMessageApiConfigId", message.text) - await provider.postStateToWebview() - break - case "autoApprovalEnabled": await updateGlobalState("autoApprovalEnabled", message.bool ?? false) await provider.postStateToWebview() diff --git a/src/services/commit-message/CommitMessageOrchestrator.ts b/src/services/commit-message/CommitMessageOrchestrator.ts deleted file mode 100644 index 40d5b5f6dd..0000000000 --- a/src/services/commit-message/CommitMessageOrchestrator.ts +++ /dev/null @@ -1,121 +0,0 @@ -import * as path from "path" -import { CommitMessageRequest, CommitMessageResult } from "./types/core" -import { GitExtensionService, GitChange } from "./GitExtensionService" -import { CommitMessageGenerator } from "./CommitMessageGenerator" -import { ICommitMessageIntegration } from "./adapters/ICommitMessageIntegration" -import { t } from "../../i18n" -import { GitStatus } from "./types" - -export interface ChangeResolution { - changes: GitChange[] - files: string[] - usedStaged: boolean -} - -export class CommitMessageOrchestrator { - async generateCommitMessage( - request: CommitMessageRequest, - integration: ICommitMessageIntegration, - messageGenerator: CommitMessageGenerator, - ): Promise { - let gitService: GitExtensionService | null = null - - try { - integration.reportProgress?.(5, t("common:commitMessage.initializing")) - gitService = new GitExtensionService(request.workspacePath) - - integration.reportProgress?.(15, t("common:commitMessage.discoveringFiles")) - const resolution = await this.resolveCommitChanges(gitService, request.selectedFiles, integration) - - if (resolution.changes.length === 0) { - const result = { message: "", error: "No changes found" } - await integration.handleResult(result) - return result - } - - integration.reportProgress?.( - 25, - t("common:commitMessage.foundChanges", { count: resolution.changes.length }), - ) - - if (!resolution.usedStaged && resolution.files.length > 0) { - integration.showMessage?.("Generating commit message from unstaged changes", "info") - } - - integration.reportProgress?.(40, t("common:commitMessage.gettingContext")) - - const gitContext = await gitService.getCommitContext( - resolution.changes, - { staged: resolution.usedStaged, includeRepoContext: true }, - resolution.files, - ) - - integration.reportProgress?.(70, t("common:commitMessage.generating")) - - const message = await messageGenerator.generateMessage({ - workspacePath: request.workspacePath, - selectedFiles: resolution.files, - gitContext, - onProgress: (update) => { - if (update.percentage !== undefined) { - const scaledPercentage = 70 + update.percentage * 0.25 - integration.reportProgress?.(scaledPercentage, update.message) - } - }, - }) - - const result = { message } - await integration.handleResult(result) - - integration.reportProgress?.(100, t("common:commitMessage.generated")) - return result - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred" - const result = { message: "", error: errorMessage } - - await integration.showMessage?.(errorMessage, "error") - await integration.handleResult(result) - - return result - } finally { - gitService?.dispose() - } - } - - private async resolveCommitChanges( - gitService: GitExtensionService, - selectedFiles?: string[], - integration?: ICommitMessageIntegration, - ): Promise { - if (selectedFiles && selectedFiles.length > 0) { - const changes: GitChange[] = selectedFiles.map((filePath) => { - const status: GitStatus = "M" - const staged = false - return { - filePath, - status, - staged, - } - }) - return { - changes, - files: selectedFiles, - usedStaged: false, - } - } - - let changes = await gitService.gatherChanges({ staged: true }) - let usedStaged = true - - if (changes.length === 0) { - changes = await gitService.gatherChanges({ staged: false }) - usedStaged = false - } - - return { - changes, - files: changes.map((change) => change.filePath), - usedStaged, - } - } -} diff --git a/src/services/commit-message/CommitMessageProvider.ts b/src/services/commit-message/CommitMessageProvider.ts index f063576a7c..a4fdf9ef38 100644 --- a/src/services/commit-message/CommitMessageProvider.ts +++ b/src/services/commit-message/CommitMessageProvider.ts @@ -3,14 +3,17 @@ import { ProviderSettingsManager } from "../../core/config/ProviderSettingsManag import { t } from "../../i18n" import { Package } from "../../shared/package" -import { CommitMessageRequest, CommitMessageResult } from "./types/core" import { CommitMessageGenerator } from "./CommitMessageGenerator" -import { VSCodeCommitMessageAdapter } from "./adapters/VSCodeCommitMessageAdapter" -import { VscGenerationRequest } from "./types" +import { GitExtensionService } from "./GitExtensionService" +import { GitChange } from "./types" + +interface VscGenerationRequest { + inputBox: { value: string } + rootUri?: vscode.Uri +} export class CommitMessageProvider implements vscode.Disposable { private generator: CommitMessageGenerator - private vscodeAdapter: VSCodeCommitMessageAdapter constructor( private context: vscode.ExtensionContext, @@ -19,7 +22,6 @@ export class CommitMessageProvider implements vscode.Disposable { const providerSettingsManager = new ProviderSettingsManager(this.context) this.generator = new CommitMessageGenerator(providerSettingsManager) - this.vscodeAdapter = new VSCodeCommitMessageAdapter(this.generator) } public async activate(): Promise { @@ -35,11 +37,125 @@ export class CommitMessageProvider implements vscode.Disposable { } private async handleVSCodeCommand(vsRequest?: VscGenerationRequest): Promise { - const request: CommitMessageRequest = { - workspacePath: this.determineWorkspacePath(vsRequest?.rootUri), + 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 gitService = new GitExtensionService(workspacePath) + + try { + reportProgress(15, t("common:commitMessage.discoveringFiles")) + const resolution = await this.resolveCommitChanges(gitService) + + 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 gitContext = await gitService.getCommitContext( + resolution.changes, + { staged: resolution.usedStaged, includeRepoContext: true }, + resolution.files, + ) + + reportProgress(70, t("common:commitMessage.generating")) + const message = await this.generator.generateMessage({ + workspacePath, + selectedFiles: resolution.files, + gitContext, + 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 { + gitService.dispose() + } + }, + ) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error occurred" + vscode.window.showErrorMessage(t("common:commitMessage.generationFailed", { errorMessage })) + } + } + + private async resolveCommitChanges(gitService: GitExtensionService): Promise<{ + changes: GitChange[] + files: string[] + usedStaged: boolean + }> { + let changes = await gitService.gatherChanges({ staged: true }) + let usedStaged = true + + if (changes.length === 0) { + changes = await gitService.gatherChanges({ staged: false }) + usedStaged = false } - await this.vscodeAdapter.generateCommitMessage(request) + 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 { @@ -55,7 +171,5 @@ export class CommitMessageProvider implements vscode.Disposable { throw new Error("Could not determine workspace path") } - public dispose(): void { - this.vscodeAdapter?.dispose() - } + public dispose(): void {} } diff --git a/src/services/commit-message/GitExtensionService.ts b/src/services/commit-message/GitExtensionService.ts index 002dfa3e21..f242ffa457 100644 --- a/src/services/commit-message/GitExtensionService.ts +++ b/src/services/commit-message/GitExtensionService.ts @@ -1,166 +1,79 @@ -import * as vscode from "vscode" import * as path from "path" -import { spawnSync } from "child_process" -import { shouldExcludeLockFile } from "./exclusionUtils" -import { RooIgnoreController } from "../../core/ignore/RooIgnoreController" +import { execFile } from "child_process" import { GitProgressOptions, GitChange, GitOptions, GitStatus } from "./types" export type { GitChange, GitOptions, GitProgressOptions } from "./types" export class GitExtensionService { - private ignoreController: RooIgnoreController | null = null - - constructor(private workspaceRoot: string) { - try { - this.ignoreController = new RooIgnoreController(workspaceRoot) - this.ignoreController.initialize() - } catch (error) { - this.ignoreController = null - } - } + constructor(private workspaceRoot: string) {} public async gatherChanges(options: GitProgressOptions): Promise { - try { - const statusOutput = this.getStatus(options) - if (!statusOutput.trim()) { - return [] - } - - const changes: GitChange[] = [] - const lines = statusOutput.split("\n").filter((line: string) => line.trim()) - - for (const line of lines) { - if (!line || line.length < 2) continue - - let statusCode: string - let filePath: string - - if (options.staged) { - const tabIndex = line.indexOf("\t") - if (tabIndex > 0) { - statusCode = line.substring(0, tabIndex).trim() - filePath = line.substring(tabIndex + 1).trim() - } else { - continue - } - } else { - if (line.length < 3) continue - - const indexStatus = line.charAt(0) - const workingStatus = line.charAt(1) - filePath = line.substring(2).trim() - - if (workingStatus !== " ") { - statusCode = workingStatus - } else if (indexStatus !== " ") { - statusCode = indexStatus - } else { - continue - } - - if (indexStatus === "?" && workingStatus === "?") { - statusCode = "?" - } - } - - if (filePath && statusCode) { - changes.push({ - filePath: path.join(this.workspaceRoot, filePath), - status: this.getChangeStatusFromCode(statusCode), - staged: options.staged, - }) - } - } - - return changes - } catch (error) { + const statusOutput = await this.getStatus(options) + if (!statusOutput) { return [] } - } - - public spawnGitWithArgs(args: string[]): string { - const result = spawnSync("git", args, { - cwd: this.workspaceRoot, - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }) - if (result.error) { - throw result.error - } + return options.staged ? this.parseNameStatus(statusOutput, true) : this.parsePorcelainStatus(statusOutput) + } - if (result.status !== 0) { - throw new Error(`Git command failed with status ${result.status}: ${result.stderr}`) - } + public async spawnGitWithArgs(args: string[]): Promise { + return new Promise((resolve, reject) => { + execFile( + "git", + args, + { + cwd: this.workspaceRoot, + encoding: "utf8", + maxBuffer: 20 * 1024 * 1024, + }, + (error, stdout, stderr) => { + if (error) { + reject(new Error(`Git command failed: ${stderr || error.message}`)) + return + } - return result.stdout + resolve(stdout) + }, + ) + }) } private async getDiffForChanges(changes: GitChange[], options: GitProgressOptions): Promise { - const { onProgress } = options || {} if (changes.length === 0) { return "" } - try { - const diffs: string[] = [] - let processedFiles = 0 - - for (const change of changes) { - const relativePath = path.relative(this.workspaceRoot, change.filePath) - - if (this.shouldIncludeFile(relativePath)) { - const stagedFlag = change.staged ?? options.staged ?? true - const diff = this.getGitDiff(change.filePath, { staged: stagedFlag }) + const diffableChanges = changes.filter((change) => change.status !== "?") + const untrackedFiles = changes.filter((change) => change.status === "?") + const parts: string[] = [] - if (diff) { - diffs.push(diff) - } - } - - processedFiles++ - this.reportProgress(onProgress, processedFiles, changes.length) + if (diffableChanges.length > 0) { + const diffArgs = this.buildDiffArgs(options.staged, diffableChanges) + const diff = await this.spawnGitWithArgs(diffArgs) + if (diff.trim()) { + parts.push(diff) } - - return diffs.join("\n") - } catch (error) { - return "" } - } - private getStatus(options: GitOptions): string { - const { staged } = options - if (staged) { - return this.spawnGitWithArgs(["diff", "--name-status", "--cached"]) - } else { - return this.spawnGitWithArgs(["status", "--porcelain"]) + if (untrackedFiles.length > 0) { + parts.push(untrackedFiles.map((change) => `New untracked file: ${change.filePath}`).join("\n")) } - } - - private getGitDiff(filePath: string, options: GitOptions): string { - const { staged } = options - - try { - if (this.isBinaryFile(filePath, staged)) { - return `Binary file ${filePath} has been ${staged ? "staged" : "modified"}` - } - if (!staged && this.isUntrackedFile(filePath)) { - return `New untracked file: ${filePath}` - } + options.onProgress?.(100) + return parts.join("\n") + } - const diffArgs = this.buildDiffArgs(staged, filePath) - return this.spawnGitWithArgs(diffArgs) - } catch (error) { - return `File ${filePath} - diff unavailable` - } + private async getStatus(options: GitOptions): Promise { + return options.staged + ? this.spawnGitWithArgs(["diff", "--name-status", "--cached", "-z"]) + : this.spawnGitWithArgs(["status", "--porcelain=v1", "-z"]) } - private getCurrentBranch(): string { + private async getCurrentBranch(): Promise { return this.spawnGitWithArgs(["branch", "--show-current"]) } - private getRecentCommits(count: number = 5): string { + private async getRecentCommits(count: number = 5): Promise { return this.spawnGitWithArgs(["log", "--oneline", `-${count}`]) } @@ -170,76 +83,180 @@ export class GitExtensionService { specificFiles?: string[], ): Promise { const { staged, includeRepoContext = true } = options + let context = "## Git Context for Commit Message Generation\n\n" + + 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" try { - let context = "## Git Context for Commit Message Generation\n\n" - - const targetChanges = - specificFiles && specificFiles.length > 0 - ? changes.filter((change) => { - const absolutePath = change.filePath - const relativePath = path.relative(this.workspaceRoot, absolutePath) - return specificFiles.some( - (file) => - file === absolutePath || - file === relativePath || - absolutePath.endsWith(file) || - relativePath === path.normalize(file), - ) - }) - : changes + const diff = await this.getDiffForChanges(targetChanges, options) + context += `### Full Diff of ${changeDescriptor} Changes${fileInfo}\n\`\`\`diff\n${diff}\n\`\`\`\n\n` + } catch (error) { + const changeType = staged ? "Staged" : "Unstaged" + context += `### Full Diff of ${changeType} Changes${fileInfo}\n\`\`\`diff\n(No diff available)\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 diff = await this.getDiffForChanges(targetChanges, options) - 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" - context += `### Full Diff of ${changeDescriptor} Changes${fileInfo}\n\`\`\`diff\n` + diff + "\n```\n\n" - } catch (error) { - const changeType = staged ? "Staged" : "Unstaged" - const fileInfo = specificFiles ? ` (${specificFiles.length} selected files)` : "" - context += `### Full Diff of ${changeType} Changes${fileInfo}\n\`\`\`diff\n(No diff available)\n\`\`\`\n\n` + const currentBranch = await this.getCurrentBranch() + if (currentBranch) { + context += "**Current branch:** `" + currentBranch.trim() + "`\n\n" + } + } catch (error) {} + + try { + const recentCommits = await this.getRecentCommits() + if (recentCommits) { + context += "**Recent commits:**\n```\n" + recentCommits + "\n```\n" + } + } catch (error) {} + } + + return context + } + + 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 } - if (targetChanges.length > 0) { - const summaryLines = targetChanges.map((change) => { - const relativePath = path.relative(this.workspaceRoot, change.filePath) - const scope = change.staged ? "staged" : "unstaged" - return `${this.getReadableStatus(change.status)} (${scope}): ${relativePath}` + const filePath = fields[++index] + if (filePath) { + changes.push({ + filePath: path.join(this.workspaceRoot, filePath), + status, + staged, }) - - 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" + return changes + } - try { - const currentBranch = this.getCurrentBranch() - if (currentBranch) { - context += "**Current branch:** `" + currentBranch.trim() + "`\n\n" - } - } catch (error) {} + private parsePorcelainStatus(output: string): GitChange[] { + const fields = this.splitNullDelimited(output) + const changes: GitChange[] = [] - try { - const recentCommits = this.getRecentCommits() - if (recentCommits) { - context += "**Recent commits:**\n```\n" + recentCommits + "\n```\n" - } - } catch (error) {} + for (let index = 0; index < fields.length; index++) { + const entry = fields[index] + if (entry.length < 4) { + continue } - return context - } catch (error) { - return "## Error generating commit context\n\nUnable to gather complete context for commit message generation." + 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 { - switch (code) { + const status = code.charAt(0) + switch (status) { case "M": case "A": case "D": @@ -247,7 +264,7 @@ export class GitExtensionService { case "C": case "U": case "?": - return code as GitStatus + return status as GitStatus default: return "Unknown" } @@ -275,65 +292,5 @@ export class GitExtensionService { } } - private shouldIncludeFile(relativePath: string): boolean { - let isValidFile = true - if (this.ignoreController) { - try { - isValidFile = this.ignoreController.validateAccess(relativePath) - } catch (error) { - isValidFile = true - } - } - return isValidFile && !shouldExcludeLockFile(relativePath) - } - - private reportProgress( - onProgress: ((percentage: number) => void) | undefined, - processed: number, - total: number, - ): void { - if (onProgress && total > 0) { - const percentage = (processed / total) * 100 - onProgress(percentage) - } - } - - private isBinaryFile(filePath: string, staged: boolean): boolean { - try { - const checkArgs = this.buildNumstatArgs(staged, filePath) - const numstatOutput = this.spawnGitWithArgs(checkArgs) - return numstatOutput.includes("-\t-\t") - } catch (error) { - return false - } - } - - private isUntrackedFile(filePath: string): boolean { - try { - const statusArgs = ["status", "--porcelain", "--", filePath] - const statusOutput = this.spawnGitWithArgs(statusArgs) - return statusOutput.startsWith("??") - } catch (error) { - return false - } - } - - private buildNumstatArgs(staged: boolean, filePath: string): string[] { - return staged ? ["diff", "--cached", "--numstat", "--", filePath] : ["diff", "--numstat", "--", filePath] - } - - private buildDiffArgs(staged: boolean, filePath: string): string[] { - return staged ? ["diff", "--cached", "--", filePath] : ["diff", "--", filePath] - } - - public dispose(): void { - try { - if (this.ignoreController) { - this.ignoreController.dispose() - } - } catch (error) { - } finally { - this.ignoreController = null - } - } + public dispose(): void {} } diff --git a/src/services/commit-message/__tests__/GitExtensionService.spec.ts b/src/services/commit-message/__tests__/GitExtensionService.spec.ts new file mode 100644 index 0000000000..044f10d03f --- /dev/null +++ b/src/services/commit-message/__tests__/GitExtensionService.spec.ts @@ -0,0 +1,81 @@ +import * as path from "path" +import { execFile } from "child_process" + +import { GitExtensionService } from "../GitExtensionService" + +vi.mock("child_process", () => ({ + execFile: vi.fn(), +})) + +const mockExecFile = vi.mocked(execFile) +const workspaceRoot = path.resolve("/repo") + +function mockGitOutput(stdout: string) { + mockExecFile.mockImplementation(((_command, _args, _options, callback?: unknown) => { + if (typeof callback === "function") { + callback(null, stdout, "") + } + }) as typeof execFile) +} + +describe("GitExtensionService", () => { + beforeEach(() => { + mockExecFile.mockReset() + }) + + it("parses staged name-status output including renames and copies", async () => { + mockGitOutput( + ["M", "src/file.ts", "R100", "src/old.ts", "src/new.ts", "C075", "src/a.ts", "src/b.ts", ""].join("\0"), + ) + + const service = new GitExtensionService(workspaceRoot) + const changes = await service.gatherChanges({ staged: true }) + + expect(mockExecFile).toHaveBeenCalledWith( + "git", + ["diff", "--name-status", "--cached", "-z"], + expect.objectContaining({ cwd: workspaceRoot }), + expect.any(Function), + ) + 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("keeps lockfiles in commit context because git state is authoritative", async () => { + mockGitOutput("diff --git a/package-lock.json b/package-lock.json\n") + + const service = new GitExtensionService(workspaceRoot) + const context = await service.getCommitContext( + [{ 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("summarizes untracked files without trying to diff them", async () => { + const service = new GitExtensionService(workspaceRoot) + const context = await service.getCommitContext( + [{ filePath: path.join(workspaceRoot, "src/new.ts"), status: "?", staged: false }], + { staged: false, includeRepoContext: false }, + ) + + expect(mockExecFile).not.toHaveBeenCalled() + expect(context).toContain(`New untracked file: ${path.join(workspaceRoot, "src/new.ts")}`) + expect(context).toContain("Untracked (unstaged): src/new.ts") + }) +}) diff --git a/src/services/commit-message/adapters/ICommitMessageAdapter.ts b/src/services/commit-message/adapters/ICommitMessageAdapter.ts deleted file mode 100644 index 53932344a4..0000000000 --- a/src/services/commit-message/adapters/ICommitMessageAdapter.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { CommitMessageRequest, CommitMessageResult } from "../types/core" - -export interface ICommitMessageAdapter { - generateCommitMessage(request: CommitMessageRequest): Promise -} diff --git a/src/services/commit-message/adapters/ICommitMessageIntegration.ts b/src/services/commit-message/adapters/ICommitMessageIntegration.ts deleted file mode 100644 index 2fb386db05..0000000000 --- a/src/services/commit-message/adapters/ICommitMessageIntegration.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { GitChange } from "../GitExtensionService" -import { CommitMessageResult } from "../types/core" - -export interface ICommitMessageIntegration { - reportProgress?(percentage: number, message?: string): void - - showMessage?(message: string, type: "info" | "error" | "warning"): Promise - - handleResult(result: CommitMessageResult): Promise -} diff --git a/src/services/commit-message/adapters/VSCodeCommitMessageAdapter.ts b/src/services/commit-message/adapters/VSCodeCommitMessageAdapter.ts deleted file mode 100644 index 772a35cd35..0000000000 --- a/src/services/commit-message/adapters/VSCodeCommitMessageAdapter.ts +++ /dev/null @@ -1,108 +0,0 @@ -import * as vscode from "vscode" -import { ICommitMessageAdapter } from "./ICommitMessageAdapter" -import { ICommitMessageIntegration } from "./ICommitMessageIntegration" -import { CommitMessageRequest, CommitMessageResult, MessageType } from "../types/core" -import { VscGenerationRequest, VSCodeMessageTypeMap } from "../types/vscode" -import { t } from "../../../i18n" -import { CommitMessageGenerator } from "../CommitMessageGenerator" -import { CommitMessageOrchestrator } from "../CommitMessageOrchestrator" - -export class VSCodeCommitMessageAdapter implements ICommitMessageAdapter { - private targetRepository: VscGenerationRequest | null = null - private orchestrator: CommitMessageOrchestrator - - constructor(private messageGenerator: CommitMessageGenerator) { - this.orchestrator = new CommitMessageOrchestrator() - } - - async generateCommitMessage(request: CommitMessageRequest): Promise { - try { - const targetRepository = await this.determineTargetRepository(request.workspacePath) - if (!targetRepository?.rootUri) { - throw new Error("Could not determine Git repository") - } - this.targetRepository = targetRepository - - return await vscode.window.withProgress( - { - location: vscode.ProgressLocation.SourceControl, - title: t("common:commitMessage.generating"), - cancellable: false, - }, - async (progress) => { - const integration: ICommitMessageIntegration = { - reportProgress: (percentage: number, message?: string) => { - progress.report({ - increment: Math.max(0, percentage - (progress as any)._lastPercentage || 0), - message: message || t("common:commitMessage.generating"), - }) - ;(progress as any)._lastPercentage = percentage - }, - - showMessage: async (message: string, type: MessageType) => { - const methodName = VSCodeMessageTypeMap[type] - const method = vscode.window[methodName] as ( - message: string, - ) => Thenable - await method(message) - }, - - handleResult: async (result: CommitMessageResult) => { - if (result.message && this.targetRepository) { - this.targetRepository.inputBox.value = result.message - } - if (result.error) { - const methodName = VSCodeMessageTypeMap["error"] - const method = vscode.window[methodName] as ( - message: string, - ) => Thenable - await method(t("common:commitMessage.generationFailed", { errorMessage: result.error })) - } - }, - } - - return this.orchestrator.generateCommitMessage(request, integration, this.messageGenerator) - }, - ) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error occurred" - return { message: "", error: errorMessage } - } - } - - private async determineTargetRepository(workspacePath: string): Promise { - try { - const gitExtension = vscode.extensions.getExtension("vscode.git") - if (!gitExtension) { - return null - } - - if (!gitExtension.isActive) { - try { - await gitExtension.activate() - } catch (activationError) { - return null - } - } - - 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 - } - } - - public dispose(): void { - this.targetRepository = null - } -} diff --git a/src/services/commit-message/exclusionUtils.ts b/src/services/commit-message/exclusionUtils.ts deleted file mode 100644 index 5d66421228..0000000000 --- a/src/services/commit-message/exclusionUtils.ts +++ /dev/null @@ -1,125 +0,0 @@ -import ignore, { Ignore } from "ignore" -import { normalize } from "path" - -const lockFiles: string[] = [ - "package-lock.json", - "npm-shrinkwrap.json", - "yarn.lock", - "pnpm-lock.yaml", - "pnpm-workspace.yaml", - "bun.lockb", - ".yarnrc.yml", - ".pnp.js", - ".pnp.cjs", - "jspm.lock", - - "Pipfile.lock", - "poetry.lock", - "pdm.lock", - ".pdm-lock.toml", - "conda-lock.yml", - "pylock.toml", - - "Gemfile.lock", - ".bundle/config", - - "composer.lock", - - "gradle.lockfile", - "lockfile.json", - "dependency-lock.json", - "dependency-reduced-pom.xml", - "coursier.lock", - - "build.sbt.lock", - - "packages.lock.json", - "paket.lock", - "project.assets.json", - - "Cargo.lock", - - "go.sum", - "Gopkg.lock", - "glide.lock", - "vendor/vendor.json", - - "build.zig.zon.lock", - - "dune.lock", - "opam.lock", - - "kotlin-js-store", - - "Package.resolved", - "Podfile.lock", - "Cartfile.resolved", - - "pubspec.lock", - - "mix.lock", - "rebar.lock", - - "stack.yaml.lock", - "cabal.project.freeze", - - "elm-stuff/exact-dependencies.json", - - "shard.lock", - - "Manifest.toml", - "JuliaManifest.toml", - - "renv.lock", - "packrat.lock", - - "nimble.lock", - - "dub.selections.json", - - "rocks.lock", - - "carton.lock", - "cpanfile.snapshot", - - "conan.lock", - "vcpkg-lock.json", - - ".terraform.lock.hcl", - "Berksfile.lock", - "Puppetfile.lock", - - "flake.lock", - - "deno.lock", - - "devcontainer.lock.json", -] - -const createLockFileIgnoreInstance = (): Ignore => { - const ignoreInstance = ignore() - - const lockFilePatterns = lockFiles.map((file) => `**/${file}`) - ignoreInstance.add(lockFilePatterns) - - const directoryPatterns = [ - "**/kotlin-js-store", - "**/kotlin-js-store/**", - "**/elm-stuff", - "**/elm-stuff/**", - "**/.yarn/cache/**", - "**/.yarn/unplugged/**", - "**/.yarn/build-state.yml", - "**/.yarn/install-state.gz", - ] - ignoreInstance.add(directoryPatterns) - - return ignoreInstance -} - -const lockFileIgnoreInstance = createLockFileIgnoreInstance() - -export function shouldExcludeLockFile(filePath: string): boolean { - const normalizedPath = normalize(filePath) - return lockFileIgnoreInstance.ignores(normalizedPath) -} diff --git a/src/services/commit-message/types.ts b/src/services/commit-message/types.ts index 6cd996cac4..aa0889bed6 100644 --- a/src/services/commit-message/types.ts +++ b/src/services/commit-message/types.ts @@ -1,9 +1,8 @@ -import * as vscode from "vscode" - export type GitStatus = "M" | "A" | "D" | "R" | "C" | "U" | "?" | "Unknown" export interface GitChange { filePath: string + oldFilePath?: string status: GitStatus staged: boolean } @@ -16,8 +15,3 @@ export interface GitProgressOptions extends GitOptions { onProgress?: (percentage: number) => void includeRepoContext?: boolean } - -export interface VscGenerationRequest { - inputBox: { value: string } - rootUri?: vscode.Uri -} diff --git a/src/services/commit-message/types/core.ts b/src/services/commit-message/types/core.ts index 039bf2b0ce..3bfed717a7 100644 --- a/src/services/commit-message/types/core.ts +++ b/src/services/commit-message/types/core.ts @@ -1,13 +1,3 @@ -export interface CommitMessageRequest { - workspacePath: string - selectedFiles?: string[] -} - -export interface CommitMessageResult { - message: string - error?: string -} - export interface GenerateMessageParams { workspacePath: string selectedFiles: string[] @@ -26,18 +16,3 @@ export interface ProgressUpdate { percentage?: number increment?: number } - -export interface ProgressTask { - execute: (progress: ProgressReporter) => Promise - title: string - location: ProgressLocation - cancellable?: boolean -} - -export interface ProgressReporter { - report(value: { message?: string; increment?: number }): void -} - -export type MessageType = "info" | "error" | "warning" - -export type ProgressLocation = "SourceControl" | "Notification" | "Window" diff --git a/src/services/commit-message/types/vscode.ts b/src/services/commit-message/types/vscode.ts deleted file mode 100644 index 7949f61bd6..0000000000 --- a/src/services/commit-message/types/vscode.ts +++ /dev/null @@ -1,12 +0,0 @@ -import * as vscode from "vscode" - -export interface VscGenerationRequest { - inputBox: { value: string } - rootUri?: vscode.Uri -} - -export const VSCodeMessageTypeMap = { - info: "showInformationMessage", - error: "showErrorMessage", - warning: "showWarningMessage", -} as const diff --git a/webview-ui/src/components/settings/CommitMessagePromptSettings.tsx b/webview-ui/src/components/settings/CommitMessagePromptSettings.tsx index 674233d86b..1ff942e879 100644 --- a/webview-ui/src/components/settings/CommitMessagePromptSettings.tsx +++ b/webview-ui/src/components/settings/CommitMessagePromptSettings.tsx @@ -1,12 +1,19 @@ import React from "react" import { useAppTranslation } from "@src/i18n/TranslationContext" -import { useExtensionState } from "@src/context/ExtensionStateContext" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@src/components/ui" -import { vscode } from "@src/utils/vscode" -const CommitMessagePromptSettings = () => { +interface CommitMessagePromptSettingsProps { + listApiConfigMeta: Array<{ id: string; name: string }> + commitMessageApiConfigId?: string + setCommitMessageApiConfigId: (value: string) => void +} + +const CommitMessagePromptSettings = ({ + listApiConfigMeta, + commitMessageApiConfigId, + setCommitMessageApiConfigId, +}: CommitMessagePromptSettingsProps) => { const { t } = useAppTranslation() - const { listApiConfigMeta, commitMessageApiConfigId, setCommitMessageApiConfigId } = useExtensionState() return (
@@ -16,10 +23,6 @@ const CommitMessagePromptSettings = () => { value={commitMessageApiConfigId || "-"} onValueChange={(value) => { setCommitMessageApiConfigId(value === "-" ? "" : value) - vscode.postMessage({ - type: "commitMessageApiConfigId", - text: value, - }) }}> diff --git a/webview-ui/src/components/settings/PromptsSettings.tsx b/webview-ui/src/components/settings/PromptsSettings.tsx index 5c94e709dd..05245ea69a 100644 --- a/webview-ui/src/components/settings/PromptsSettings.tsx +++ b/webview-ui/src/components/settings/PromptsSettings.tsx @@ -24,6 +24,8 @@ import CommitMessagePromptSettings from "./CommitMessagePromptSettings" interface PromptsSettingsProps { customSupportPrompts: Record setCustomSupportPrompts: (prompts: Record) => void + commitMessageApiConfigId?: string + setCommitMessageApiConfigId?: (value: string) => void includeTaskHistoryInEnhance?: boolean setIncludeTaskHistoryInEnhance?: (value: boolean) => void } @@ -31,6 +33,8 @@ interface PromptsSettingsProps { const PromptsSettings = ({ customSupportPrompts, setCustomSupportPrompts, + commitMessageApiConfigId, + setCommitMessageApiConfigId, includeTaskHistoryInEnhance: propsIncludeTaskHistoryInEnhance, setIncludeTaskHistoryInEnhance: propsSetIncludeTaskHistoryInEnhance, }: PromptsSettingsProps) => { @@ -246,7 +250,13 @@ const PromptsSettings = ({
)} - {activeSupportOption === "COMMIT_MESSAGE" && } + {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) From 9b306b6e53aeff8d9fb434b3082db6324f04e3e7 Mon Sep 17 00:00:00 2001 From: Mirrowel <28632877+Mirrowel@users.noreply.github.com> Date: Sun, 17 May 2026 10:50:51 +0200 Subject: [PATCH 3/4] =?UTF-8?q?feat(scm):=20=E2=9C=A8=20include=20untracke?= =?UTF-8?q?d=20file=20diffs=20and=20expose=20context=20warnings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The commit message generator's git context gathering has been enhanced to provide better information to the LLM and surface underlying issues to the user. - Switch git command execution from `execFile` to `spawn` for improved process handling and output streaming. - Add detection for binary files using `git diff --numstat` and file buffer peeking, preventing raw binary data from polluting the context prompt. - Generate full inline diff representations for new, untracked text files rather than just listing their paths. - Update context gathering to collect and report non-fatal warnings (e.g., unavailable branch or missing commit history) via VS Code notifications instead of silently ignoring them. - Add fallback logic and console warnings when failing to load a selected commit message API profile. Also in this commit: - test(scm): add test coverage for `CommitMessageGenerator` and update `GitExtensionService` specs --- src/i18n/locales/en/common.json | 1 + .../commit-message/CommitMessageGenerator.ts | 7 +- .../commit-message/CommitMessageProvider.ts | 11 +- .../commit-message/GitExtensionService.ts | 178 ++++++++++++++---- .../__tests__/CommitMessageGenerator.spec.ts | 176 +++++++++++++++++ .../__tests__/GitExtensionService.spec.ts | 126 +++++++++++-- src/services/commit-message/types.ts | 5 + 7 files changed, 449 insertions(+), 55 deletions(-) create mode 100644 src/services/commit-message/__tests__/CommitMessageGenerator.spec.ts diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 4c28faa5bf..de4f767ce7 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -268,6 +268,7 @@ "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", diff --git a/src/services/commit-message/CommitMessageGenerator.ts b/src/services/commit-message/CommitMessageGenerator.ts index 5c6afc94cc..27b73c8d11 100644 --- a/src/services/commit-message/CommitMessageGenerator.ts +++ b/src/services/commit-message/CommitMessageGenerator.ts @@ -130,7 +130,12 @@ FINAL REMINDER: Your message MUST be COMPLETELY DIFFERENT from the previous mess if (providerSettings.apiProvider) { configToUse = providerSettings } - } catch (error) {} + } catch (error) { + console.warn( + `Failed to load commit message API profile ${commitMessageApiConfigId}; falling back to current API configuration`, + error, + ) + } } const filteredPrompts = Object.fromEntries( diff --git a/src/services/commit-message/CommitMessageProvider.ts b/src/services/commit-message/CommitMessageProvider.ts index a4fdf9ef38..ecbb56cdd2 100644 --- a/src/services/commit-message/CommitMessageProvider.ts +++ b/src/services/commit-message/CommitMessageProvider.ts @@ -79,17 +79,24 @@ export class CommitMessageProvider implements vscode.Disposable { } reportProgress(40, t("common:commitMessage.gettingContext")) - const gitContext = await gitService.getCommitContext( + const gitContextResult = await gitService.getCommitContextResult( 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, + gitContext: gitContextResult.context, onProgress: (update) => { if (update.percentage !== undefined) { reportProgress(70 + update.percentage * 0.25, update.message) diff --git a/src/services/commit-message/GitExtensionService.ts b/src/services/commit-message/GitExtensionService.ts index f242ffa457..659379b920 100644 --- a/src/services/commit-message/GitExtensionService.ts +++ b/src/services/commit-message/GitExtensionService.ts @@ -1,6 +1,7 @@ import * as path from "path" -import { execFile } from "child_process" -import { GitProgressOptions, GitChange, GitOptions, GitStatus } from "./types" +import { promises as fs } from "fs" +import { spawn } from "child_process" +import { GitProgressOptions, GitChange, GitContextResult, GitOptions, GitStatus } from "./types" export type { GitChange, GitOptions, GitProgressOptions } from "./types" @@ -18,23 +19,26 @@ export class GitExtensionService { public async spawnGitWithArgs(args: string[]): Promise { return new Promise((resolve, reject) => { - execFile( - "git", - args, - { - cwd: this.workspaceRoot, - encoding: "utf8", - maxBuffer: 20 * 1024 * 1024, - }, - (error, stdout, stderr) => { - if (error) { - reject(new Error(`Git command failed: ${stderr || error.message}`)) - return - } - + 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}`}`)) + }) }) } @@ -43,7 +47,8 @@ export class GitExtensionService { return "" } - const diffableChanges = changes.filter((change) => change.status !== "?") + 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[] = [] @@ -56,13 +61,106 @@ export class GitExtensionService { } if (untrackedFiles.length > 0) { - parts.push(untrackedFiles.map((change) => `New untracked file: ${change.filePath}`).join("\n")) + 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.spawnGitWithArgs(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: GitOptions): Promise { return options.staged ? this.spawnGitWithArgs(["diff", "--name-status", "--cached", "-z"]) @@ -77,13 +175,14 @@ export class GitExtensionService { return this.spawnGitWithArgs(["log", "--oneline", `-${count}`]) } - public async getCommitContext( + public async getCommitContextResult( changes: GitChange[], options: GitProgressOptions, specificFiles?: string[], - ): Promise { + ): Promise { const { staged, includeRepoContext = true } = options let context = "## Git Context for Commit Message Generation\n\n" + const warnings: string[] = [] const targetChanges = this.filterChanges(changes, specificFiles) const fileInfo = specificFiles ? ` (${specificFiles.length} selected files)` : "" @@ -91,13 +190,8 @@ export class GitExtensionService { const allUnstaged = targetChanges.every((change) => !change.staged) const changeDescriptor = allStaged ? "Staged" : allUnstaged ? "Unstaged" : "Selected" - try { - const diff = await this.getDiffForChanges(targetChanges, options) - context += `### Full Diff of ${changeDescriptor} Changes${fileInfo}\n\`\`\`diff\n${diff}\n\`\`\`\n\n` - } catch (error) { - const changeType = staged ? "Staged" : "Unstaged" - context += `### Full Diff of ${changeType} Changes${fileInfo}\n\`\`\`diff\n(No diff available)\n\`\`\`\n\n` - } + 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) => { @@ -126,17 +220,37 @@ export class GitExtensionService { if (currentBranch) { context += "**Current branch:** `" + currentBranch.trim() + "`\n\n" } - } catch (error) {} + } 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) {} + } catch (error) { + warnings.push(`Recent commits unavailable: ${this.getErrorMessage(error)}`) + } + } + + if (warnings.length > 0) { + context += "\n### Context Warnings\n```\n" + warnings.join("\n") + "\n```\n" } - return context + return { context, warnings } + } + + public async getCommitContext( + changes: GitChange[], + options: GitProgressOptions, + specificFiles?: string[], + ): Promise { + return (await this.getCommitContextResult(changes, options, specificFiles)).context + } + + private getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) } private parseNameStatus(output: string, staged: boolean): GitChange[] { 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..20c098163b --- /dev/null +++ b/src/services/commit-message/__tests__/CommitMessageGenerator.spec.ts @@ -0,0 +1,176 @@ +import type { ProviderSettings } from "@roo-code/types" + +import { CommitMessageGenerator } from "../CommitMessageGenerator" +import { singleCompletionHandler } from "../../../utils/single-completion-handler" + +const { mockContextProxy, mockCaptureEvent, mockAddCustomInstructions } = vi.hoisted(() => ({ + mockContextProxy: { + isInitialized: true, + getProviderSettings: vi.fn(), + getValue: vi.fn(), + }, + mockCaptureEvent: vi.fn(), + mockAddCustomInstructions: vi.fn(), +})) +const mockSingleCompletionHandler = vi.mocked(singleCompletionHandler) + +vi.mock("../../../core/config/ContextProxy", () => ({ + ContextProxy: { + get instance() { + return mockContextProxy + }, + }, +})) + +vi.mock("../../../core/prompts/sections/custom-instructions", () => ({ + addCustomInstructions: (...args: unknown[]) => mockAddCustomInstructions(...args), +})) + +vi.mock("../../../utils/single-completion-handler", () => ({ + singleCompletionHandler: vi.fn(), +})) + +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureEvent: mockCaptureEvent, + }, + }, +})) + +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(), + } + + beforeEach(() => { + vi.clearAllMocks() + mockContextProxy.isInitialized = true + mockContextProxy.getProviderSettings.mockReturnValue(defaultConfig) + mockContextProxy.getValue.mockImplementation((key: string) => { + switch (key) { + case "commitMessageApiConfigId": + return undefined + case "listApiConfigMeta": + return [] + case "customSupportPrompts": + return {} + default: + return undefined + } + }) + mockAddCustomInstructions.mockResolvedValue("Follow repo commit rules.") + mockSingleCompletionHandler.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 for Commit Message Generation + +### 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 = new CommitMessageGenerator(providerSettingsManager as any) + + const message = await generator.generateMessage({ + workspacePath: "/repo", + selectedFiles: ["src/new.ts"], + gitContext, + }) + + expect(message).toBe("feat(core): add commit generator") + expect(mockSingleCompletionHandler).toHaveBeenCalledTimes(1) + const [config, prompt] = mockSingleCompletionHandler.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(mockCaptureEvent).toHaveBeenCalledTimes(1) + }) + + it("uses the selected commit-message API profile when configured", async () => { + mockContextProxy.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 + } + }) + mockSingleCompletionHandler.mockResolvedValue("fix(git): include untracked file diffs") + const generator = new CommitMessageGenerator(providerSettingsManager as any) + + 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(mockSingleCompletionHandler).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 () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + mockContextProxy.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 = new CommitMessageGenerator(providerSettingsManager as any) + + await generator.generateMessage({ + workspacePath: "/repo", + selectedFiles: ["src/new.ts"], + gitContext: "diff --git a/src/new.ts b/src/new.ts", + }) + + expect(mockSingleCompletionHandler).toHaveBeenCalledWith(defaultConfig, expect.any(String)) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to load commit message API profile deleted-profile"), + expect.any(Error), + ) + warnSpy.mockRestore() + }) + + it("asks for a different message when regenerating for the same git context", async () => { + mockSingleCompletionHandler.mockResolvedValueOnce("feat(git): collect commit context") + mockSingleCompletionHandler.mockResolvedValueOnce("chore(git): improve diff handling") + const generator = new CommitMessageGenerator(providerSettingsManager as any) + 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 = mockSingleCompletionHandler.mock.calls[1][1] + expect(secondPrompt).toContain("GENERATE A COMPLETELY DIFFERENT COMMIT MESSAGE") + expect(secondPrompt).toContain('The previous message was: "feat(git): collect commit context"') + expect(secondPrompt).toContain(gitContext) + }) +}) diff --git a/src/services/commit-message/__tests__/GitExtensionService.spec.ts b/src/services/commit-message/__tests__/GitExtensionService.spec.ts index 044f10d03f..e161a25d50 100644 --- a/src/services/commit-message/__tests__/GitExtensionService.spec.ts +++ b/src/services/commit-message/__tests__/GitExtensionService.spec.ts @@ -1,41 +1,61 @@ +import * as os from "os" import * as path from "path" -import { execFile } from "child_process" +import { EventEmitter } from "events" +import { promises as fs } from "fs" +import { spawn } from "child_process" import { GitExtensionService } from "../GitExtensionService" vi.mock("child_process", () => ({ - execFile: vi.fn(), + spawn: vi.fn(), })) -const mockExecFile = vi.mocked(execFile) +const mockSpawn = vi.mocked(spawn) const workspaceRoot = path.resolve("/repo") -function mockGitOutput(stdout: string) { - mockExecFile.mockImplementation(((_command, _args, _options, callback?: unknown) => { - if (typeof callback === "function") { - callback(null, stdout, "") +function mockGitCommand(stdout: string, stderr = "", code = 0) { + mockSpawn.mockImplementationOnce((() => { + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter & { setEncoding: ReturnType } + stderr: EventEmitter & { setEncoding: ReturnType } } - }) as typeof execFile) + + 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("GitExtensionService", () => { beforeEach(() => { - mockExecFile.mockReset() + mockSpawn.mockReset() }) it("parses staged name-status output including renames and copies", async () => { - mockGitOutput( + mockGitCommand( ["M", "src/file.ts", "R100", "src/old.ts", "src/new.ts", "C075", "src/a.ts", "src/b.ts", ""].join("\0"), ) const service = new GitExtensionService(workspaceRoot) const changes = await service.gatherChanges({ staged: true }) - expect(mockExecFile).toHaveBeenCalledWith( + expect(mockSpawn).toHaveBeenCalledWith( "git", ["diff", "--name-status", "--cached", "-z"], expect.objectContaining({ cwd: workspaceRoot }), - expect.any(Function), ) expect(changes).toEqual([ { filePath: path.join(workspaceRoot, "src/file.ts"), status: "M", staged: true }, @@ -55,7 +75,8 @@ describe("GitExtensionService", () => { }) it("keeps lockfiles in commit context because git state is authoritative", async () => { - mockGitOutput("diff --git a/package-lock.json b/package-lock.json\n") + mockGitCommand("1\t1\tpackage-lock.json\n") + mockGitCommand("diff --git a/package-lock.json b/package-lock.json\n") const service = new GitExtensionService(workspaceRoot) const context = await service.getCommitContext( @@ -67,15 +88,80 @@ describe("GitExtensionService", () => { expect(context).toContain("Modified (staged): package-lock.json") }) - it("summarizes untracked files without trying to diff them", async () => { + it("includes full new-file diffs for untracked text files", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "zoo-commit-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 service = new GitExtensionService(tempRoot) + const context = await service.getCommitContext([{ 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-commit-context-")) + try { + const filePath = path.join(tempRoot, "image.bin") + await fs.writeFile(filePath, Buffer.from([0, 1, 2, 3])) + + const service = new GitExtensionService(tempRoot) + const context = await service.getCommitContext([{ 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 service = new GitExtensionService(workspaceRoot) - const context = await service.getCommitContext( - [{ filePath: path.join(workspaceRoot, "src/new.ts"), status: "?", staged: false }], - { staged: false, includeRepoContext: false }, + + await expect( + service.getCommitContext( + [{ 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 service = new GitExtensionService(workspaceRoot) + const result = await service.getCommitContextResult( + [{ filePath: path.join(workspaceRoot, "src/file.ts"), status: "M", staged: true }], + { staged: true, includeRepoContext: true }, ) - expect(mockExecFile).not.toHaveBeenCalled() - expect(context).toContain(`New untracked file: ${path.join(workspaceRoot, "src/new.ts")}`) - expect(context).toContain("Untracked (unstaged): src/new.ts") + expect(result.warnings).toEqual([ + expect.stringContaining("Current branch unavailable"), + expect.stringContaining("Recent commits unavailable"), + ]) + expect(result.context).toContain("### Context Warnings") + expect(result.context).toContain("diff --git a/src/file.ts b/src/file.ts") }) }) diff --git a/src/services/commit-message/types.ts b/src/services/commit-message/types.ts index aa0889bed6..bf438bc0d0 100644 --- a/src/services/commit-message/types.ts +++ b/src/services/commit-message/types.ts @@ -7,6 +7,11 @@ export interface GitChange { staged: boolean } +export interface GitContextResult { + context: string + warnings: string[] +} + export interface GitOptions { staged: boolean } From 9bdcc6d2fcfd5d092029761c1a5585a0f563fc21 Mon Sep 17 00:00:00 2001 From: Mirrowel <28632877+Mirrowel@users.noreply.github.com> Date: Sun, 17 May 2026 12:03:17 +0200 Subject: [PATCH 4/4] =?UTF-8?q?refactor(scm):=20=F0=9F=94=A8=20extract=20g?= =?UTF-8?q?it=20context=20collection=20and=20introduce=20dependency=20inje?= =?UTF-8?q?ction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract `GitExtensionService` into a standalone `git-context` module and rename it to `GitContextCollector` with a consolidated `collect` API. - Introduce dependency injection to `CommitMessageGenerator` to accept external services (`getContextProxy`, `completePrompt`, `captureGenerated`, etc.) via constructor parameters. - Remove `vi.mock` global module overrides in `CommitMessageGenerator.spec.ts` in favor of injected test doubles. - Add an integration test to verify the end-to-end commit message generation flow using a temporary Git repository. Also in this commit: - fix(scm): append `--untracked-files=all` to the git status command to ensure files inside untracked directories are individually listed and diffed --- .../commit-message/CommitMessageGenerator.ts | 57 +++++++--- .../commit-message/CommitMessageProvider.ts | 17 ++- ...ommitMessageGeneration.integration.spec.ts | 79 ++++++++++++++ .../__tests__/CommitMessageGenerator.spec.ts | 102 +++++++----------- .../GitContextCollector.ts} | 64 +++++++---- .../__tests__/GitContextCollector.spec.ts} | 71 ++++++++---- src/services/git-context/index.ts | 9 ++ .../{commit-message => git-context}/types.ts | 8 +- 8 files changed, 277 insertions(+), 130 deletions(-) create mode 100644 src/services/commit-message/__tests__/CommitMessageGeneration.integration.spec.ts rename src/services/{commit-message/GitExtensionService.ts => git-context/GitContextCollector.ts} (87%) rename src/services/{commit-message/__tests__/GitExtensionService.spec.ts => git-context/__tests__/GitContextCollector.spec.ts} (65%) create mode 100644 src/services/git-context/index.ts rename src/services/{commit-message => git-context}/types.ts (64%) diff --git a/src/services/commit-message/CommitMessageGenerator.ts b/src/services/commit-message/CommitMessageGenerator.ts index 27b73c8d11..ad0efd813c 100644 --- a/src/services/commit-message/CommitMessageGenerator.ts +++ b/src/services/commit-message/CommitMessageGenerator.ts @@ -1,20 +1,47 @@ import { ContextProxy } from "../../core/config/ContextProxy" import { ProviderSettingsManager } from "../../core/config/ProviderSettingsManager" -import { singleCompletionHandler } from "../../utils/single-completion-handler" +import { singleCompletionHandler as defaultSingleCompletionHandler } from "../../utils/single-completion-handler" import { supportPrompt } from "../../shared/support-prompt" -import { addCustomInstructions } from "../../core/prompts/sections/custom-instructions" +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) { + 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 { @@ -31,7 +58,7 @@ export class CommitMessageGenerator { this.previousGitContext = gitContext this.previousCommitMessage = generatedMessage - TelemetryService.instance.captureEvent(TelemetryEventName.COMMIT_MSG_GENERATED) + this.dependencies.captureGenerated() onProgress?.({ message: "Commit message generated successfully", @@ -48,7 +75,7 @@ export class CommitMessageGenerator { async buildPrompt(gitContext: string, options: PromptOptions, workspacePath: string): Promise { const { customSupportPrompts = {}, previousContext, previousMessage } = options - const customInstructions = await addCustomInstructions("", "", workspacePath, "commit", { + const customInstructions = await this.dependencies.addCustomInstructions("", "", workspacePath, "commit", { language: "en", }) @@ -106,21 +133,21 @@ FINAL REMINDER: Your message MUST be COMPLETELY DIFFERENT from the previous mess workspacePath: string, onProgress?: (progress: ProgressUpdate) => void, ): Promise { - const contextProxy = ContextProxy.instance + 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") - const listApiConfigMeta = contextProxy.getValue("listApiConfigMeta") || [] - const customSupportPrompts = contextProxy.getValue("customSupportPrompts") || {} + 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: string }) => id === commitMessageApiConfigId) - ) { + if (commitMessageApiConfigId && listApiConfigMeta.find(({ id }) => id === commitMessageApiConfigId)) { try { await this.providerSettingsManager.initialize() const { name: _, ...providerSettings } = await this.providerSettingsManager.getProfile({ @@ -131,7 +158,7 @@ FINAL REMINDER: Your message MUST be COMPLETELY DIFFERENT from the previous mess configToUse = providerSettings } } catch (error) { - console.warn( + this.dependencies.logger.warn( `Failed to load commit message API profile ${commitMessageApiConfigId}; falling back to current API configuration`, error, ) @@ -153,7 +180,7 @@ FINAL REMINDER: Your message MUST be COMPLETELY DIFFERENT from the previous mess increment: 10, }) - const response = await singleCompletionHandler(configToUse, prompt) + const response = await this.dependencies.completePrompt(configToUse, prompt) onProgress?.({ message: "Processing AI response...", diff --git a/src/services/commit-message/CommitMessageProvider.ts b/src/services/commit-message/CommitMessageProvider.ts index ecbb56cdd2..8a0628c8cc 100644 --- a/src/services/commit-message/CommitMessageProvider.ts +++ b/src/services/commit-message/CommitMessageProvider.ts @@ -2,10 +2,9 @@ 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" -import { GitExtensionService } from "./GitExtensionService" -import { GitChange } from "./types" interface VscGenerationRequest { inputBox: { value: string } @@ -61,11 +60,11 @@ export class CommitMessageProvider implements vscode.Disposable { } reportProgress(5, t("common:commitMessage.initializing")) - const gitService = new GitExtensionService(workspacePath) + const gitCollector = new GitContextCollector(workspacePath) try { reportProgress(15, t("common:commitMessage.discoveringFiles")) - const resolution = await this.resolveCommitChanges(gitService) + const resolution = await this.resolveCommitChanges(gitCollector) if (resolution.changes.length === 0) { vscode.window.showInformationMessage(t("common:commitMessage.noChanges")) @@ -79,7 +78,7 @@ export class CommitMessageProvider implements vscode.Disposable { } reportProgress(40, t("common:commitMessage.gettingContext")) - const gitContextResult = await gitService.getCommitContextResult( + const gitContextResult = await gitCollector.collectContext( resolution.changes, { staged: resolution.usedStaged, includeRepoContext: true }, resolution.files, @@ -107,7 +106,7 @@ export class CommitMessageProvider implements vscode.Disposable { targetRepository.inputBox.value = message reportProgress(100, t("common:commitMessage.generated")) } finally { - gitService.dispose() + gitCollector.dispose() } }, ) @@ -117,16 +116,16 @@ export class CommitMessageProvider implements vscode.Disposable { } } - private async resolveCommitChanges(gitService: GitExtensionService): Promise<{ + private async resolveCommitChanges(gitCollector: GitContextCollector): Promise<{ changes: GitChange[] files: string[] usedStaged: boolean }> { - let changes = await gitService.gatherChanges({ staged: true }) + let changes = await gitCollector.gatherChanges({ staged: true }) let usedStaged = true if (changes.length === 0) { - changes = await gitService.gatherChanges({ staged: false }) + changes = await gitCollector.gatherChanges({ staged: false }) usedStaged = false } 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 index 20c098163b..299d4d6764 100644 --- a/src/services/commit-message/__tests__/CommitMessageGenerator.spec.ts +++ b/src/services/commit-message/__tests__/CommitMessageGenerator.spec.ts @@ -1,42 +1,6 @@ import type { ProviderSettings } from "@roo-code/types" import { CommitMessageGenerator } from "../CommitMessageGenerator" -import { singleCompletionHandler } from "../../../utils/single-completion-handler" - -const { mockContextProxy, mockCaptureEvent, mockAddCustomInstructions } = vi.hoisted(() => ({ - mockContextProxy: { - isInitialized: true, - getProviderSettings: vi.fn(), - getValue: vi.fn(), - }, - mockCaptureEvent: vi.fn(), - mockAddCustomInstructions: vi.fn(), -})) -const mockSingleCompletionHandler = vi.mocked(singleCompletionHandler) - -vi.mock("../../../core/config/ContextProxy", () => ({ - ContextProxy: { - get instance() { - return mockContextProxy - }, - }, -})) - -vi.mock("../../../core/prompts/sections/custom-instructions", () => ({ - addCustomInstructions: (...args: unknown[]) => mockAddCustomInstructions(...args), -})) - -vi.mock("../../../utils/single-completion-handler", () => ({ - singleCompletionHandler: vi.fn(), -})) - -vi.mock("@roo-code/telemetry", () => ({ - TelemetryService: { - instance: { - captureEvent: mockCaptureEvent, - }, - }, -})) describe("CommitMessageGenerator", () => { const defaultConfig: ProviderSettings = { apiProvider: "openai", openAiApiKey: "default-key" } @@ -45,12 +9,30 @@ describe("CommitMessageGenerator", () => { 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() - mockContextProxy.isInitialized = true - mockContextProxy.getProviderSettings.mockReturnValue(defaultConfig) - mockContextProxy.getValue.mockImplementation((key: string) => { + contextProxy.isInitialized = true + contextProxy.getProviderSettings.mockReturnValue(defaultConfig) + contextProxy.getValue.mockImplementation((key: string) => { switch (key) { case "commitMessageApiConfigId": return undefined @@ -62,14 +44,14 @@ describe("CommitMessageGenerator", () => { return undefined } }) - mockAddCustomInstructions.mockResolvedValue("Follow repo commit rules.") - mockSingleCompletionHandler.mockResolvedValue("```\nfeat(core): add commit generator\n```") + 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 for Commit Message Generation + const gitContext = `## Git Context ### Full Diff of Staged Changes \`\`\`diff @@ -80,7 +62,7 @@ new file mode 100644 @@ -0,0 +1,1 @@ +export const value = 1 \`\`\`` - const generator = new CommitMessageGenerator(providerSettingsManager as any) + const generator = createGenerator() const message = await generator.generateMessage({ workspacePath: "/repo", @@ -89,17 +71,17 @@ new file mode 100644 }) expect(message).toBe("feat(core): add commit generator") - expect(mockSingleCompletionHandler).toHaveBeenCalledTimes(1) - const [config, prompt] = mockSingleCompletionHandler.mock.calls[0] + 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(mockCaptureEvent).toHaveBeenCalledTimes(1) + expect(captureGenerated).toHaveBeenCalledTimes(1) }) it("uses the selected commit-message API profile when configured", async () => { - mockContextProxy.getValue.mockImplementation((key: string) => { + contextProxy.getValue.mockImplementation((key: string) => { switch (key) { case "commitMessageApiConfigId": return "commit-profile" @@ -111,8 +93,8 @@ new file mode 100644 return undefined } }) - mockSingleCompletionHandler.mockResolvedValue("fix(git): include untracked file diffs") - const generator = new CommitMessageGenerator(providerSettingsManager as any) + completePrompt.mockResolvedValue("fix(git): include untracked file diffs") + const generator = createGenerator() await generator.generateMessage({ workspacePath: "/repo", @@ -122,15 +104,14 @@ new file mode 100644 expect(providerSettingsManager.initialize).toHaveBeenCalledTimes(1) expect(providerSettingsManager.getProfile).toHaveBeenCalledWith({ id: "commit-profile" }) - expect(mockSingleCompletionHandler).toHaveBeenCalledWith( + 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 () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) - mockContextProxy.getValue.mockImplementation((key: string) => { + contextProxy.getValue.mockImplementation((key: string) => { switch (key) { case "commitMessageApiConfigId": return "deleted-profile" @@ -143,7 +124,7 @@ new file mode 100644 } }) providerSettingsManager.getProfile.mockRejectedValue(new Error("missing profile")) - const generator = new CommitMessageGenerator(providerSettingsManager as any) + const generator = createGenerator() await generator.generateMessage({ workspacePath: "/repo", @@ -151,26 +132,25 @@ new file mode 100644 gitContext: "diff --git a/src/new.ts b/src/new.ts", }) - expect(mockSingleCompletionHandler).toHaveBeenCalledWith(defaultConfig, expect.any(String)) - expect(warnSpy).toHaveBeenCalledWith( + expect(completePrompt).toHaveBeenCalledWith(defaultConfig, expect.any(String)) + expect(warn).toHaveBeenCalledWith( expect.stringContaining("Failed to load commit message API profile deleted-profile"), expect.any(Error), ) - warnSpy.mockRestore() }) it("asks for a different message when regenerating for the same git context", async () => { - mockSingleCompletionHandler.mockResolvedValueOnce("feat(git): collect commit context") - mockSingleCompletionHandler.mockResolvedValueOnce("chore(git): improve diff handling") - const generator = new CommitMessageGenerator(providerSettingsManager as any) + 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 = mockSingleCompletionHandler.mock.calls[1][1] + 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 commit context"') + expect(secondPrompt).toContain('The previous message was: "feat(git): collect git context"') expect(secondPrompt).toContain(gitContext) }) }) diff --git a/src/services/commit-message/GitExtensionService.ts b/src/services/git-context/GitContextCollector.ts similarity index 87% rename from src/services/commit-message/GitExtensionService.ts rename to src/services/git-context/GitContextCollector.ts index 659379b920..8019416abc 100644 --- a/src/services/commit-message/GitExtensionService.ts +++ b/src/services/git-context/GitContextCollector.ts @@ -1,14 +1,27 @@ import * as path from "path" import { promises as fs } from "fs" import { spawn } from "child_process" -import { GitProgressOptions, GitChange, GitContextResult, GitOptions, GitStatus } from "./types" - -export type { GitChange, GitOptions, GitProgressOptions } from "./types" - -export class GitExtensionService { +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: GitProgressOptions): Promise { + public async gatherChanges(options: GitContextCollectorOptions): Promise { const statusOutput = await this.getStatus(options) if (!statusOutput) { return [] @@ -17,7 +30,14 @@ export class GitExtensionService { return options.staged ? this.parseNameStatus(statusOutput, true) : this.parsePorcelainStatus(statusOutput) } - public async spawnGitWithArgs(args: string[]): Promise { + 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, @@ -42,7 +62,7 @@ export class GitExtensionService { }) } - private async getDiffForChanges(changes: GitChange[], options: GitProgressOptions): Promise { + private async getDiffForChanges(changes: GitChange[], options: GitContextCollectorOptions): Promise { if (changes.length === 0) { return "" } @@ -54,7 +74,7 @@ export class GitExtensionService { if (diffableChanges.length > 0) { const diffArgs = this.buildDiffArgs(options.staged, diffableChanges) - const diff = await this.spawnGitWithArgs(diffArgs) + const diff = await this.runGit(diffArgs) if (diff.trim()) { parts.push(diff) } @@ -89,7 +109,7 @@ export class GitExtensionService { } const args = this.buildNumstatArgs(staged, change) - const output = await this.spawnGitWithArgs(args) + const output = await this.runGit(args) if (output.includes("-\t-\t")) { binaryFiles.add(change.filePath) } @@ -161,27 +181,27 @@ export class GitExtensionService { return diffLines.join("\n") } - private async getStatus(options: GitOptions): Promise { + private async getStatus(options: GitContextOptions): Promise { return options.staged - ? this.spawnGitWithArgs(["diff", "--name-status", "--cached", "-z"]) - : this.spawnGitWithArgs(["status", "--porcelain=v1", "-z"]) + ? this.runGit(["diff", "--name-status", "--cached", "-z"]) + : this.runGit(["status", "--porcelain=v1", "-z", "--untracked-files=all"]) } private async getCurrentBranch(): Promise { - return this.spawnGitWithArgs(["branch", "--show-current"]) + return this.runGit(["branch", "--show-current"]) } private async getRecentCommits(count: number = 5): Promise { - return this.spawnGitWithArgs(["log", "--oneline", `-${count}`]) + return this.runGit(["log", "--oneline", `-${count}`]) } - public async getCommitContextResult( + public async collectContext( changes: GitChange[], - options: GitProgressOptions, + options: GitContextCollectorOptions, specificFiles?: string[], ): Promise { const { staged, includeRepoContext = true } = options - let context = "## Git Context for Commit Message Generation\n\n" + let context = "## Git Context\n\n" const warnings: string[] = [] const targetChanges = this.filterChanges(changes, specificFiles) @@ -235,18 +255,18 @@ export class GitExtensionService { } if (warnings.length > 0) { - context += "\n### Context Warnings\n```\n" + warnings.join("\n") + "\n```\n" + context += "\n### Git Context Warnings\n```\n" + warnings.join("\n") + "\n```\n" } return { context, warnings } } - public async getCommitContext( + public async getContext( changes: GitChange[], - options: GitProgressOptions, + options: GitContextCollectorOptions, specificFiles?: string[], ): Promise { - return (await this.getCommitContextResult(changes, options, specificFiles)).context + return (await this.collectContext(changes, options, specificFiles)).context } private getErrorMessage(error: unknown): string { diff --git a/src/services/commit-message/__tests__/GitExtensionService.spec.ts b/src/services/git-context/__tests__/GitContextCollector.spec.ts similarity index 65% rename from src/services/commit-message/__tests__/GitExtensionService.spec.ts rename to src/services/git-context/__tests__/GitContextCollector.spec.ts index e161a25d50..133b6cc94c 100644 --- a/src/services/commit-message/__tests__/GitExtensionService.spec.ts +++ b/src/services/git-context/__tests__/GitContextCollector.spec.ts @@ -4,7 +4,7 @@ import { EventEmitter } from "events" import { promises as fs } from "fs" import { spawn } from "child_process" -import { GitExtensionService } from "../GitExtensionService" +import { GitContextCollector } from "../GitContextCollector" vi.mock("child_process", () => ({ spawn: vi.fn(), @@ -39,7 +39,7 @@ function mockGitCommand(stdout: string, stderr = "", code = 0) { }) as unknown as typeof spawn) } -describe("GitExtensionService", () => { +describe("GitContextCollector", () => { beforeEach(() => { mockSpawn.mockReset() }) @@ -49,8 +49,8 @@ describe("GitExtensionService", () => { ["M", "src/file.ts", "R100", "src/old.ts", "src/new.ts", "C075", "src/a.ts", "src/b.ts", ""].join("\0"), ) - const service = new GitExtensionService(workspaceRoot) - const changes = await service.gatherChanges({ staged: true }) + const collector = new GitContextCollector(workspaceRoot) + const changes = await collector.gatherChanges({ staged: true }) expect(mockSpawn).toHaveBeenCalledWith( "git", @@ -74,12 +74,26 @@ describe("GitExtensionService", () => { ]) }) - it("keeps lockfiles in commit context because git state is authoritative", async () => { + 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 service = new GitExtensionService(workspaceRoot) - const context = await service.getCommitContext( + 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 }, ) @@ -88,15 +102,30 @@ describe("GitExtensionService", () => { 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-commit-context-")) + 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 service = new GitExtensionService(tempRoot) - const context = await service.getCommitContext([{ filePath, status: "?", staged: false }], { + const collector = new GitContextCollector(tempRoot) + const context = await collector.getContext([{ filePath, status: "?", staged: false }], { staged: false, includeRepoContext: false, }) @@ -112,13 +141,13 @@ describe("GitExtensionService", () => { }) it("summarizes untracked binary files without binary payload", async () => { - const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "zoo-commit-context-")) + 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 service = new GitExtensionService(tempRoot) - const context = await service.getCommitContext([{ filePath, status: "?", staged: false }], { + const collector = new GitContextCollector(tempRoot) + const context = await collector.getContext([{ filePath, status: "?", staged: false }], { staged: false, includeRepoContext: false, }) @@ -135,13 +164,13 @@ describe("GitExtensionService", () => { mockGitCommand("1\t1\tsrc/file.ts\n") mockGitCommand("", "fatal: bad revision", 128) - const service = new GitExtensionService(workspaceRoot) + const collector = new GitContextCollector(workspaceRoot) await expect( - service.getCommitContext( - [{ filePath: path.join(workspaceRoot, "src/file.ts"), status: "M", staged: true }], - { staged: true, includeRepoContext: false }, - ), + collector.getContext([{ filePath: path.join(workspaceRoot, "src/file.ts"), status: "M", staged: true }], { + staged: true, + includeRepoContext: false, + }), ).rejects.toThrow("fatal: bad revision") }) @@ -151,8 +180,8 @@ describe("GitExtensionService", () => { mockGitCommand("", "fatal: branch unavailable", 128) mockGitCommand("", "fatal: log unavailable", 128) - const service = new GitExtensionService(workspaceRoot) - const result = await service.getCommitContextResult( + 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 }, ) @@ -161,7 +190,7 @@ describe("GitExtensionService", () => { expect.stringContaining("Current branch unavailable"), expect.stringContaining("Recent commits unavailable"), ]) - expect(result.context).toContain("### Context Warnings") + 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/commit-message/types.ts b/src/services/git-context/types.ts similarity index 64% rename from src/services/commit-message/types.ts rename to src/services/git-context/types.ts index bf438bc0d0..d440523d6d 100644 --- a/src/services/commit-message/types.ts +++ b/src/services/git-context/types.ts @@ -12,11 +12,15 @@ export interface GitContextResult { warnings: string[] } -export interface GitOptions { +export interface GitContextCollection extends GitContextResult { + changes: GitChange[] +} + +export interface GitContextOptions { staged: boolean } -export interface GitProgressOptions extends GitOptions { +export interface GitContextCollectorOptions extends GitContextOptions { onProgress?: (percentage: number) => void includeRepoContext?: boolean }