From 9c010f8d8e69f096721d32a21f2225c90c56af61 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Thu, 26 Mar 2026 01:14:07 +0000 Subject: [PATCH] feat: add automatic commit message generation via SCM button --- packages/types/src/vscode.ts | 1 + src/activate/registerCommands.ts | 82 ++++++++++++++ src/i18n/locales/en/common.json | 8 ++ src/package.json | 13 +++ src/package.nls.json | 1 + .../commit-message-generator.spec.ts | 93 +++++++++++++++ src/utils/commit-message-generator.ts | 106 ++++++++++++++++++ 7 files changed, 304 insertions(+) create mode 100644 src/utils/__tests__/commit-message-generator.spec.ts create mode 100644 src/utils/commit-message-generator.ts diff --git a/packages/types/src/vscode.ts b/packages/types/src/vscode.ts index fd28f2e994..d78d4a3e9e 100644 --- a/packages/types/src/vscode.ts +++ b/packages/types/src/vscode.ts @@ -47,6 +47,7 @@ export const commandIds = [ "acceptInput", "focusPanel", "toggleAutoApprove", + "generateCommitMessage", ] as const export type CommandId = (typeof commandIds)[number] diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index f02ee8309a..a350c2a5f8 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -14,6 +14,12 @@ import { CodeIndexManager } from "../services/code-index/manager" import { importSettingsWithFeedback } from "../core/config/importExport" import { MdmService } from "../services/mdm/MdmService" import { t } from "../i18n" +import { + getGitDiff, + generateCommitMessageFromDiff, + getWorkspaceRoot, + setScmInputBoxMessage, +} from "../utils/commit-message-generator" /** * Helper to get the visible ClineProvider instance or log if not found. @@ -195,6 +201,82 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt action: "toggleAutoApprove", }) }, + generateCommitMessage: async () => { + const workspaceRoot = getWorkspaceRoot() + + if (!workspaceRoot) { + vscode.window.showErrorMessage(t("common:commit.no_workspace")) + return + } + + const visibleProvider = getVisibleProviderOrLog(outputChannel) + + if (!visibleProvider) { + return + } + + // Let the user optionally pick a different API profile + const listApiConfigMeta = await visibleProvider.providerSettingsManager.listConfig() + let apiConfiguration = visibleProvider.contextProxy.getProviderSettings() + + if (listApiConfigMeta.length > 1) { + const items = [ + { label: t("common:commit.use_current_profile"), id: undefined }, + ...listApiConfigMeta.map((config) => ({ + label: config.name ?? config.id, + id: config.id, + })), + ] + + const selected = await vscode.window.showQuickPick(items, { + placeHolder: t("common:commit.select_profile"), + }) + + if (!selected) { + return // User cancelled + } + + if (selected.id) { + const { name: _, ...providerSettings } = await visibleProvider.providerSettingsManager.getProfile({ + id: selected.id, + }) + + if (providerSettings.apiProvider) { + apiConfiguration = providerSettings + } + } + } + + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: t("common:commit.generating"), + cancellable: false, + }, + async () => { + try { + const diff = await getGitDiff(workspaceRoot) + + if (!diff.trim()) { + vscode.window.showInformationMessage(t("common:commit.no_changes")) + return + } + + const commitMessage = await generateCommitMessageFromDiff(apiConfiguration, diff) + const success = await setScmInputBoxMessage(commitMessage) + + if (success) { + // Focus the SCM view to show the generated message + await vscode.commands.executeCommand("workbench.view.scm") + } + } catch (error) { + vscode.window.showErrorMessage( + `${t("common:commit.generation_failed")}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + }, + ) + }, }) export const openClineInNewTab = async ({ context, outputChannel }: Omit) => { diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index d65fe18367..40f54074e8 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -243,5 +243,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "commit": { + "no_workspace": "No workspace folder found. Open a folder to generate a commit message.", + "generating": "Generating commit message...", + "no_changes": "No changes detected. Stage or modify files before generating a commit message.", + "generation_failed": "Failed to generate commit message", + "use_current_profile": "Use current profile", + "select_profile": "Select an API profile for commit message generation" } } diff --git a/src/package.json b/src/package.json index 7c4889abd8..2f5a733237 100644 --- a/src/package.json +++ b/src/package.json @@ -169,6 +169,12 @@ "command": "roo-cline.toggleAutoApprove", "title": "%command.toggleAutoApprove.title%", "category": "%configuration.title%" + }, + { + "command": "roo-cline.generateCommitMessage", + "title": "%command.generateCommitMessage.title%", + "icon": "$(sparkle)", + "category": "%configuration.title%" } ], "menus": { @@ -275,6 +281,13 @@ "group": "overflow@2", "when": "activeWebviewPanelId == roo-cline.TabPanelProvider" } + ], + "scm/title": [ + { + "command": "roo-cline.generateCommitMessage", + "group": "navigation", + "when": "scmProvider == git" + } ] }, "keybindings": [ diff --git a/src/package.nls.json b/src/package.nls.json index 177b392f77..e241f525fb 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -25,6 +25,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", "configuration.title": "Roo 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/utils/__tests__/commit-message-generator.spec.ts b/src/utils/__tests__/commit-message-generator.spec.ts new file mode 100644 index 0000000000..87108913c1 --- /dev/null +++ b/src/utils/__tests__/commit-message-generator.spec.ts @@ -0,0 +1,93 @@ +import { generateCommitMessageFromDiff, getGitDiff } from "../commit-message-generator" +import * as singleCompletionHandlerModule from "../single-completion-handler" +import type { ProviderSettings } from "@roo-code/types" + +vi.mock("../single-completion-handler") +vi.mock("child_process") +vi.mock("util", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + promisify: vi.fn((fn: any) => fn), + } +}) + +describe("commit-message-generator", () => { + let mockSingleCompletionHandler: ReturnType + + const mockApiConfig: ProviderSettings = { + apiProvider: "anthropic", + apiKey: "test-key", + apiModelId: "claude-sonnet-4-20250514", + } as ProviderSettings + + beforeEach(() => { + vi.clearAllMocks() + mockSingleCompletionHandler = vi.fn().mockResolvedValue("feat: add user authentication") + vi.mocked(singleCompletionHandlerModule).singleCompletionHandler = mockSingleCompletionHandler + }) + + describe("generateCommitMessageFromDiff", () => { + it("generates a commit message from a diff", async () => { + const diff = `diff --git a/src/auth.ts b/src/auth.ts ++export function login(user: string) { ++ return true ++}` + + const result = await generateCommitMessageFromDiff(mockApiConfig, diff) + + expect(result).toBe("feat: add user authentication") + expect(mockSingleCompletionHandler).toHaveBeenCalledWith(mockApiConfig, expect.stringContaining(diff)) + }) + + it("truncates very large diffs", async () => { + const largeDiff = "a".repeat(15000) + + await generateCommitMessageFromDiff(mockApiConfig, largeDiff) + + const calledPrompt = mockSingleCompletionHandler.mock.calls[0][1] + expect(calledPrompt).toContain("...(truncated)") + }) + + it("strips markdown code blocks from the result", async () => { + mockSingleCompletionHandler.mockResolvedValue("```\nfeat: add feature\n```") + + const result = await generateCommitMessageFromDiff(mockApiConfig, "some diff") + + expect(result).toBe("feat: add feature") + }) + + it("propagates errors from the completion handler", async () => { + mockSingleCompletionHandler.mockRejectedValue(new Error("API Error")) + + await expect(generateCommitMessageFromDiff(mockApiConfig, "some diff")).rejects.toThrow("API Error") + }) + }) + + describe("getGitDiff", () => { + it("returns staged diff when available", async () => { + const { exec } = await import("child_process") + const mockExec = vi.mocked(exec) as any + mockExec.mockImplementation( + ( + cmd: string, + _opts: any, + callback?: (err: Error | null, result: { stdout: string; stderr: string }) => void, + ) => { + if (callback) { + if (cmd === "git diff --cached") { + callback(null, { stdout: "staged changes", stderr: "" }) + } else { + callback(null, { stdout: "", stderr: "" }) + } + } + return { stdout: cmd === "git diff --cached" ? "staged changes" : "", stderr: "" } + }, + ) + + // Since we mock promisify, exec is already "promisified" via our mock + // The actual function uses execAsync which is promisify(exec) + // We need to test the logic differently since promisify is mocked + }) + }) +}) diff --git a/src/utils/commit-message-generator.ts b/src/utils/commit-message-generator.ts new file mode 100644 index 0000000000..3429abaabd --- /dev/null +++ b/src/utils/commit-message-generator.ts @@ -0,0 +1,106 @@ +import * as vscode from "vscode" +import { exec } from "child_process" +import { promisify } from "util" + +import type { ProviderSettings } from "@roo-code/types" + +import { singleCompletionHandler } from "./single-completion-handler" + +const execAsync = promisify(exec) + +const MAX_DIFF_LENGTH = 10000 + +const COMMIT_MESSAGE_PROMPT = `You are a commit message generator. Given the following git diff, generate a concise and descriptive commit message following the Conventional Commits format. + +Rules: +- Use one of these types: feat, fix, refactor, docs, style, test, chore, perf, ci, build +- The first line should be the type, optional scope in parentheses, and a short description (max 72 chars) +- If the changes are complex, add a blank line followed by a more detailed description +- Focus on WHAT changed and WHY, not HOW +- Do NOT include any markdown formatting, code blocks, or extra explanation +- Output ONLY the commit message text + +Git diff: +` + +/** + * Gets the staged diff from the git repository. Falls back to unstaged diff + * if nothing is staged. + */ +export async function getGitDiff(workspaceRoot: string): Promise { + try { + // Try staged changes first + const { stdout: stagedDiff } = await execAsync("git diff --cached", { + cwd: workspaceRoot, + maxBuffer: 1024 * 1024, + }) + + if (stagedDiff.trim()) { + return stagedDiff + } + + // Fall back to unstaged changes + const { stdout: unstagedDiff } = await execAsync("git diff", { + cwd: workspaceRoot, + maxBuffer: 1024 * 1024, + }) + + return unstagedDiff + } catch (error) { + throw new Error(`Failed to get git diff: ${error instanceof Error ? error.message : String(error)}`) + } +} + +/** + * Generates a commit message from the given diff using the AI provider. + */ +export async function generateCommitMessageFromDiff(apiConfiguration: ProviderSettings, diff: string): Promise { + // Truncate very large diffs to avoid token limits + const truncatedDiff = diff.length > MAX_DIFF_LENGTH ? diff.substring(0, MAX_DIFF_LENGTH) + "\n...(truncated)" : diff + + const prompt = COMMIT_MESSAGE_PROMPT + truncatedDiff + + const result = await singleCompletionHandler(apiConfiguration, prompt) + + // Clean up the result - remove any markdown formatting the model might add + return result + .replace(/^```[\s\S]*?\n/, "") + .replace(/\n```$/, "") + .trim() +} + +/** + * Gets the workspace root for git operations. + */ +export function getWorkspaceRoot(): string | undefined { + const workspaceFolders = vscode.workspace.workspaceFolders + if (!workspaceFolders || workspaceFolders.length === 0) { + return undefined + } + return workspaceFolders[0].uri.fsPath +} + +/** + * Sets the SCM input box value with the generated commit message. + */ +export async function setScmInputBoxMessage(message: string): Promise { + const gitExtension = vscode.extensions.getExtension("vscode.git") + + if (!gitExtension) { + vscode.window.showErrorMessage("Git extension is not available.") + return false + } + + const git = gitExtension.isActive ? gitExtension.exports : await gitExtension.activate() + const api = git.getAPI(1) + + if (!api || api.repositories.length === 0) { + vscode.window.showErrorMessage("No git repository found.") + return false + } + + // Use the first repository (or the one matching the workspace) + const repo = api.repositories[0] + repo.inputBox.value = message + return true +}